plan-review 1.1.0 → 1.1.2

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.
Files changed (44) hide show
  1. package/dist/browser/app.js +38 -38
  2. package/dist/browser/index.html +129 -38
  3. package/dist/index.js +30 -305
  4. package/dist/index.js.map +7 -1
  5. package/package.json +12 -13
  6. package/README.md +0 -149
  7. package/dist/formatter.d.ts +0 -2
  8. package/dist/formatter.js +0 -60
  9. package/dist/formatter.js.map +0 -1
  10. package/dist/index.d.ts +0 -2
  11. package/dist/navigator.d.ts +0 -5
  12. package/dist/navigator.js +0 -94
  13. package/dist/navigator.js.map +0 -1
  14. package/dist/output.d.ts +0 -7
  15. package/dist/output.js +0 -93
  16. package/dist/output.js.map +0 -1
  17. package/dist/parser.d.ts +0 -3
  18. package/dist/parser.js +0 -265
  19. package/dist/parser.js.map +0 -1
  20. package/dist/renderer.d.ts +0 -3
  21. package/dist/renderer.js +0 -78
  22. package/dist/renderer.js.map +0 -1
  23. package/dist/server/assets.d.ts +0 -1
  24. package/dist/server/assets.js +0 -25
  25. package/dist/server/assets.js.map +0 -1
  26. package/dist/server/routes.d.ts +0 -14
  27. package/dist/server/routes.js +0 -192
  28. package/dist/server/routes.js.map +0 -1
  29. package/dist/server/server.d.ts +0 -7
  30. package/dist/server/server.js +0 -23
  31. package/dist/server/server.js.map +0 -1
  32. package/dist/session.d.ts +0 -25
  33. package/dist/session.js +0 -133
  34. package/dist/session.js.map +0 -1
  35. package/dist/transport.d.ts +0 -32
  36. package/dist/transport.js +0 -62
  37. package/dist/transport.js.map +0 -1
  38. package/dist/types.d.ts +0 -34
  39. package/dist/types.js +0 -2
  40. package/dist/types.js.map +0 -1
  41. package/examples/demo-browser.gif +0 -0
  42. package/examples/demo-plan.md +0 -129
  43. package/examples/renderer-fixture.md +0 -246
  44. package/skills/plan-review/SKILL.md +0 -70
package/dist/index.js CHANGED
@@ -1,306 +1,31 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from 'commander';
3
- import { readFileSync, mkdirSync, copyFileSync } from 'node:fs';
4
- import { existsSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
- import { dirname, join } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
- import * as readline from 'node:readline';
9
- import chalk from 'chalk';
10
- import { resolve as resolvePath, dirname as dirnamePath } from 'node:path';
11
- import { parse } from './parser.js';
12
- import { navigate } from './navigator.js';
13
- import { formatReview } from './formatter.js';
14
- import { writeOutput, isClaudeAvailable } from './output.js';
15
- import { HttpTransport } from './transport.js';
16
- import { spawnSync } from 'node:child_process';
17
- import { createRequire } from 'node:module';
18
- import { loadSession, saveSession, clearSession, computeContentHash, listSessions, getSessionDir } from './session.js';
19
- const require = createRequire(import.meta.url);
20
- const { version } = require('../package.json');
21
- const program = new Command();
22
- program
23
- .name('plan-review')
24
- .description('Interactive CLI for reviewing AI-generated markdown plans')
25
- .version(version)
26
- .argument('[file]', 'Path to markdown file (omit to read stdin)')
27
- .option('-o, --output <target>', 'Output target: stdout, clipboard, file, claude')
28
- .option('--output-file <path>', 'Custom output file path (with --output file)')
29
- .option('--split-by <strategy>', 'Force split strategy: heading, separator')
30
- .option('--fresh', 'Skip session resume, start clean review')
31
- .option('--cli', 'Use the terminal review UI instead of the browser (SSH/CI/headless)')
32
- .action(async (file, opts) => {
33
- try {
34
- await run(file, opts);
35
- }
36
- catch (err) {
37
- if (err instanceof Error) {
38
- const cancelled = err.message.startsWith('Review cancelled');
39
- console.error(cancelled ? chalk.yellow(err.message) : chalk.red(`Error: ${err.message}`));
40
- }
41
- process.exit(1);
42
- }
43
- });
44
- program
45
- .command('install-skill')
46
- .description('Install Claude Code skill to ~/.claude/skills/plan-review/')
47
- .action(() => {
48
- const __dirname = dirname(fileURLToPath(import.meta.url));
49
- const src = join(__dirname, '..', 'skills', 'plan-review', 'SKILL.md');
50
- if (!existsSync(src)) {
51
- console.error(chalk.red('Skill file not found in package. Expected at: ' + src));
52
- process.exit(1);
53
- }
54
- const dest = join(homedir(), '.claude', 'skills', 'plan-review');
55
- mkdirSync(dest, { recursive: true });
56
- copyFileSync(src, join(dest, 'SKILL.md'));
57
- console.error(chalk.green(`Skill installed to ${dest}/SKILL.md`));
58
- console.error(chalk.dim('Claude Code will auto-discover it. Try: "I want to review this plan"'));
59
- });
60
- program
61
- .command('sessions')
62
- .description('List all saved review sessions')
63
- .action(() => {
64
- const sessions = listSessions();
65
- const dir = getSessionDir();
66
- if (sessions.length === 0) {
67
- console.error(chalk.dim(`No saved sessions. (${dir})`));
68
- process.exit(0);
69
- }
70
- console.error(chalk.bold(`Saved review sessions (${dir}):\n`));
71
- for (const s of sessions) {
72
- const age = formatRelativeTime(s.lastModified);
73
- let status = '';
74
- if (s.stale === true)
75
- status = chalk.yellow(' | plan file changed since last review');
76
- else if (s.stale === null)
77
- status = chalk.red(' | plan file not found');
78
- console.error(` ${s.planPath}`);
79
- console.error(chalk.dim(` ${s.commentCount} comment${s.commentCount !== 1 ? 's' : ''} | last modified ${age}${status}\n`));
80
- }
81
- });
82
- program.parse();
83
- async function run(file, opts) {
84
- // Validate explicit output target early, before the review starts
85
- const validTargets = ['stdout', 'clipboard', 'file', 'claude'];
86
- if (opts.output !== undefined) {
87
- const explicitTarget = opts.output;
88
- if (!validTargets.includes(explicitTarget)) {
89
- throw new Error(`Invalid output target: "${opts.output}". Use: ${validTargets.join(', ')}`);
90
- }
91
- // Fail fast: check claude availability before starting review
92
- if (explicitTarget === 'claude' && !isClaudeAvailable()) {
93
- console.error(chalk.red('Claude CLI not found in PATH.'));
94
- console.error(chalk.dim('Install: https://docs.anthropic.com/en/docs/claude-code'));
95
- console.error(chalk.yellow('Will fall back to stdout after review.'));
96
- }
97
- }
98
- // Read input — track whether it came from stdin
99
- const inputFromStdin = !file && !process.stdin.isTTY;
100
- const input = readInput(file);
101
- if (!input.trim()) {
102
- console.error(chalk.yellow('Empty file, nothing to review.'));
103
- process.exit(0);
104
- }
105
- // Parse
106
- const splitStrategy = opts.splitBy === 'heading' ? 'heading'
107
- : opts.splitBy === 'separator' ? 'separator'
108
- : 'auto';
109
- const doc = parse(input, splitStrategy);
110
- console.error(chalk.dim(`Detected mode: ${doc.mode} | ${doc.sections.length} sections`));
111
- // Session resume logic
112
- const absPath = file ? resolvePath(file) : null;
113
- const contentHash = computeContentHash(input);
114
- let restoredActiveSection = null;
115
- if (absPath) {
116
- if (opts.fresh) {
117
- clearSession(absPath);
118
- }
119
- else {
120
- const session = loadSession(absPath, contentHash);
121
- if (session && session.comments.length > 0) {
122
- if (!session.stale) {
123
- console.error(chalk.green(`Resuming review (${session.comments.length} comment${session.comments.length !== 1 ? 's' : ''}).`));
124
- doc.comments = session.comments;
125
- restoredActiveSection = session.activeSection;
126
- }
127
- else {
128
- // Prompt user for stale session
129
- const answer = await promptYesNo(`Plan file changed since last review (${session.comments.length} comment${session.comments.length !== 1 ? 's' : ''}). Resume anyway?`, inputFromStdin);
130
- if (answer) {
131
- console.error(chalk.yellow('Resuming with stale session.'));
132
- doc.comments = session.comments;
133
- restoredActiveSection = session.activeSection;
134
- }
135
- else {
136
- clearSession(absPath);
137
- }
138
- }
139
- }
140
- }
141
- }
142
- // Navigate (interactive review or browser)
143
- let reviewed;
144
- if (!opts.cli) {
145
- const transport = new HttpTransport();
146
- transport.sendDocument(doc);
147
- transport.setInitialActiveSection(restoredActiveSection);
148
- // Plan-file directory anchors relative image paths via /_assets/<rel>.
149
- transport.setAssetBaseDir(absPath ? dirnamePath(absPath) : null);
150
- if (absPath) {
151
- transport.onSessionSave((comments, activeSection) => {
152
- saveSession(absPath, contentHash, comments, activeSection);
153
- });
154
- }
155
- const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes — overall ceiling
156
- const HEARTBEAT_TIMEOUT_MS = 30 * 1000; // 30s without a heartbeat while visible = browser gone
157
- const reviewPromise = new Promise((resolve, reject) => {
158
- const idleTimer = setTimeout(() => reject(new Error('Browser review timed out after 30 minutes of inactivity')), IDLE_TIMEOUT_MS);
159
- let heartbeatTimer = null;
160
- const armHeartbeat = () => {
161
- if (heartbeatTimer)
162
- clearTimeout(heartbeatTimer);
163
- heartbeatTimer = setTimeout(() => {
164
- clearTimeout(idleTimer);
165
- reject(new Error('Review cancelled: browser closed (heartbeat lost)'));
166
- }, HEARTBEAT_TIMEOUT_MS);
167
- };
168
- const clearAll = () => {
169
- clearTimeout(idleTimer);
170
- if (heartbeatTimer)
171
- clearTimeout(heartbeatTimer);
172
- heartbeatTimer = null;
173
- };
174
- transport.onHeartbeat(armHeartbeat);
175
- transport.onPause(() => {
176
- if (heartbeatTimer)
177
- clearTimeout(heartbeatTimer);
178
- heartbeatTimer = null;
179
- });
180
- transport.onCancel(() => {
181
- clearAll();
182
- reject(new Error('Review cancelled: browser closed'));
183
- });
184
- transport.onReviewSubmit((comments) => {
185
- clearAll();
186
- resolve(comments);
187
- });
188
- });
189
- const { url } = await transport.start(0);
190
- process.stderr.write(`Review server running at ${url}\n`);
191
- try {
192
- const openCmd = process.platform === 'darwin' ? 'open'
193
- : process.platform === 'win32' ? 'start'
194
- : 'xdg-open';
195
- spawnSync(openCmd, [url], { stdio: 'ignore' });
196
- }
197
- catch {
198
- process.stderr.write(`Open ${url} in your browser\n`);
199
- }
200
- try {
201
- doc.comments = await reviewPromise;
202
- }
203
- finally {
204
- await transport.stop();
205
- }
206
- reviewed = doc;
207
- }
208
- else {
209
- const onCommentChange = absPath
210
- ? () => saveSession(absPath, contentHash, doc.comments, null)
211
- : undefined;
212
- reviewed = await navigate(doc, inputFromStdin, onCommentChange);
213
- }
214
- // Clear session after successful review completion
215
- if (absPath)
216
- clearSession(absPath);
217
- // Determine output target after review is complete
218
- let outputTarget;
219
- if (opts.output !== undefined) {
220
- outputTarget = opts.output;
221
- }
222
- else {
223
- outputTarget = await promptOutputTarget(inputFromStdin);
224
- // Check claude availability after prompting
225
- if (outputTarget === 'claude' && !isClaudeAvailable()) {
226
- console.error(chalk.red('Claude CLI not found in PATH.'));
227
- console.error(chalk.dim('Install: https://docs.anthropic.com/en/docs/claude-code'));
228
- console.error(chalk.yellow('Falling back to stdout.'));
229
- outputTarget = 'stdout';
230
- }
231
- }
232
- // Format and output
233
- const output = formatReview(reviewed);
234
- writeOutput(output, outputTarget, { outputFile: opts.outputFile, inputFile: file });
235
- }
236
- async function promptOutputTarget(inputFromStdin) {
237
- const ttyInput = inputFromStdin
238
- ? (await import('node:fs')).createReadStream('/dev/tty')
239
- : process.stdin;
240
- const rl = readline.createInterface({
241
- input: ttyInput,
242
- output: process.stderr,
243
- });
244
- const answer = await new Promise((resolve) => {
245
- rl.question(chalk.cyan('> Output: (s)tdout, (c)lipboard, (f)ile, cl(a)ude? '), (a) => resolve(a.trim().toLowerCase()));
246
- });
247
- rl.close();
248
- switch (answer) {
249
- case 's':
250
- case 'stdout': return 'stdout';
251
- case 'c':
252
- case 'clipboard': return 'clipboard';
253
- case 'f':
254
- case 'file': return 'file';
255
- case 'a':
256
- case 'claude': return 'claude';
257
- default: return 'stdout';
258
- }
259
- }
260
- async function promptYesNo(message, inputFromStdin) {
261
- const ttyInput = inputFromStdin
262
- ? (await import('node:fs')).createReadStream('/dev/tty')
263
- : process.stdin;
264
- const rl = readline.createInterface({
265
- input: ttyInput,
266
- output: process.stderr,
267
- });
268
- const answer = await new Promise((resolve) => {
269
- rl.question(chalk.yellow(`${message} (y/n) `), (a) => resolve(a.trim().toLowerCase()));
270
- });
271
- rl.close();
272
- return answer === 'y' || answer === 'yes';
273
- }
274
- function readInput(file) {
275
- if (file) {
276
- if (!existsSync(file)) {
277
- throw new Error(`File not found: ${file}`);
278
- }
279
- return readFileSync(file, 'utf-8');
280
- }
281
- // Read from stdin (piped)
282
- if (!process.stdin.isTTY) {
283
- return readFileSync('/dev/stdin', 'utf-8');
284
- }
285
- // No file, no stdin pipe — show help
286
- program.help();
287
- return ''; // unreachable
288
- }
289
- function formatRelativeTime(iso) {
290
- const ms = Date.now() - new Date(iso).getTime();
291
- const seconds = Math.floor(ms / 1000);
292
- if (seconds < 60)
293
- return 'just now';
294
- const minutes = Math.floor(seconds / 60);
295
- if (minutes < 60)
296
- return `${minutes}m ago`;
297
- const hours = Math.floor(minutes / 60);
298
- if (hours < 24)
299
- return `${hours}h ago`;
300
- const days = Math.floor(hours / 24);
301
- if (days < 7)
302
- return `${days}d ago`;
303
- const weeks = Math.floor(days / 7);
304
- return `${weeks}w ago`;
305
- }
306
- //# sourceMappingURL=index.js.map
2
+ #!/usr/bin/env node
3
+ import{Command as rt}from"commander";import{readFileSync as ge,mkdirSync as st,copyFileSync as at}from"node:fs";import{existsSync as ve}from"node:fs";import{homedir as ct}from"node:os";import{dirname as lt,join as N,resolve as ut}from"node:path";import{fileURLToPath as mt}from"node:url";import p from"chalk";function L(e,t="auto"){let n=e.split(`
4
+ `),i=we(n),o=Se(n);return t==="auto"?ye(e)?Ce(e,i,o):M(e,i,o):t==="separator"?E(e,i,o):M(e,i,o)}function we(e){let t=e.find(n=>/^# /.test(n));return t?t.replace(/^# /,"").trim():"Untitled"}function Se(e){let t={};for(let n of e.slice(0,20)){let i=n.match(/^\*\*(\w[\w\s]*?):\*\*\s*(.+)/);i&&(t[i[1].trim()]=i[2].trim())}return t}function ye(e){let t=e.replace(/```[\s\S]*?```/g,""),n=/^## /m.test(t)&&/^### /m.test(t),i=/\*\*Depends On:\*\*/m.test(t)||/\*\*Blocks:\*\*/m.test(t)||/\*\*Verification:\*\*/m.test(t)||/\*\*Related Files:\*\*/m.test(t);return n&&i}function M(e,t,n){let i=be(e);return i.length===0?E(e,t,n):{title:t,metadata:n,mode:"generic",sections:i.map((o,r)=>({id:`section-${r+1}`,heading:o.heading,level:o.level,body:o.body})),comments:[]}}function be(e){let t=e.split(`
5
+ `),n=[],i="",o=0,r=[],s=(e.match(/^## /gm)||[]).length,a=(e.match(/^### /gm)||[]).length,c=s>0?2:a>0?3:0;if(c===0)return[];let u=new RegExp(`^${"#".repeat(c)} (.+)`),l=!1;for(let m of t){if(m.startsWith("```")){l=!l,i&&r.push(m);continue}if(l){i&&r.push(m);continue}let f=m.match(u);f?(i&&n.push({heading:i,level:o,body:r.join(`
6
+ `).trim()}),i=f[1].trim(),o=c,r=[]):i&&r.push(m)}return i&&n.push({heading:i,level:o,body:r.join(`
7
+ `).trim()}),n}function E(e,t,n){let i=e.split(/\n---\n/).filter(o=>o.trim().length>=5);return i.length<=1?{title:t,metadata:n,mode:"generic",sections:[{id:"section-1",heading:t,level:1,body:e.trim()}],comments:[]}:{title:t,metadata:n,mode:"generic",sections:i.map((o,r)=>{let a=o.trim().split(`
8
+ `)[0].replace(/^#+\s*/,"").trim();return{id:`section-${r+1}`,heading:a||`Section ${r+1}`,level:2,body:o.trim()}}),comments:[]}}function Ce(e,t,n){let i=e.split(`
9
+ `),o=[],r=0,s=0,a="",c="",u=0,l=[],m=!1;function f(){if(!c)return;let h=l.join(`
10
+ `).trim();if(u===2){r++,s=0,a=`milestone-${r}`,o.push({id:a,heading:c,level:2,body:h});return}s++;let d=`${r}.${s}`;o.push({id:d,heading:c,level:3,body:h,parent:a,dependencies:$e(h),relatedFiles:Te(h),verification:xe(h)})}for(let h of i){if(h.startsWith("```")){m=!m,l.push(h);continue}if(m){l.push(h);continue}let d=h.match(/^## (.+)/),R=h.match(/^### (.+)/);d?(f(),c=d[1].trim(),u=2,l=[]):R?(f(),c=R[1].trim(),u=3,l=[]):l.push(h)}return f(),{title:t,metadata:n,mode:"plan",sections:o,comments:[]}}function $e(e){let t=e.match(/\*\*Depends On:\*\*\s*(.+)/),n=e.match(/\*\*Blocks:\*\*\s*(.+)/),i=o=>{let r=o.trim();return r==="(none)"||r===""?[]:r.split(/,\s*/).map(s=>s.trim())};return{dependsOn:t?i(t[1]):[],blocks:n?i(n[1]):[]}}function Te(e){let t=[],n=e.split(`
11
+ `),i=!1;for(let o of n){if(/\*\*Related Files:\*\*/.test(o)){i=!0;continue}if(i){let r=o.match(/^- `(.+)`(.*)$/);if(r){let s=r[2].trim();t.push(s?`${r[1]} ${s}`:r[1])}else(o.trim()===""||/^\*\*/.test(o.trim()))&&(i=!1)}}return t}function xe(e){let t=e.match(/\*\*Verification:\*\*\s*`(.+?)`/);return t?t[1]:void 0}import{createHash as J}from"node:crypto";import{mkdirSync as Re,readFileSync as H,writeFileSync as He,unlinkSync as _,readdirSync as Pe,existsSync as U}from"node:fs";import{join as P,resolve as ke}from"node:path";import{homedir as Ie}from"node:os";function Oe(e){let t=ke(e);return J("sha256").update(t).digest("hex").slice(0,16)}function k(e){return P(y(),Oe(e)+".json")}function y(){let e=P(Ie(),".plan-review","sessions");return Re(e,{recursive:!0}),e}function I(e){return`sha256:${J("sha256").update(e).digest("hex")}`}function b(e,t,n,i){try{let o={version:1,planPath:e,contentHash:t,comments:n,activeSection:i,lastModified:new Date().toISOString()},r=k(e);He(r,JSON.stringify(o,null,2),"utf-8")}catch(o){console.warn(`[plan-review] Failed to save session: ${o.message}`)}}function W(e,t){let n=k(e);if(!U(n))return null;let i;try{i=H(n,"utf-8")}catch{return null}let o;try{o=JSON.parse(i)}catch{console.warn(`[plan-review] Corrupt session file, removing: ${n}`);try{_(n)}catch{}return null}return{comments:o.comments.map(s=>({...s,timestamp:new Date(s.timestamp)})),activeSection:o.activeSection,stale:o.contentHash!==t}}function C(e){let t=k(e);try{_(t)}catch{}}function z(){let e=y(),t;try{t=Pe(e)}catch{return[]}let n=[];for(let i of t){if(!i.endsWith(".json"))continue;let o=P(e,i),r;try{let a=H(o,"utf-8");r=JSON.parse(a)}catch{console.warn(`[plan-review] Skipping corrupt session file: ${o}`);continue}let s;if(!U(r.planPath))s=null;else try{let a=H(r.planPath,"utf-8");s=I(a)!==r.contentHash}catch{s=null}n.push({planPath:r.planPath,commentCount:r.comments.length,lastModified:r.lastModified,stale:s})}return n}function O(e){return e.replace(/([\\*_`~\[\]#>|])/g,"\\$1")}function De(e){return[...e].sort((t,n)=>{let i=t.anchor?.startLine??1/0,o=n.anchor?.startLine??1/0;return i-o})}function Fe(e){return e==="approved"?"Approved":"Comment"}function V(e,t){let n=new Set(e.comments.map(a=>a.sectionId)),i=e.sections.filter(a=>e.mode==="plan"?a.level===3:a.level>=2),o=i.filter(a=>n.has(a.id)),r=[];r.push(`# Plan Review: ${e.title}`),r.push(""),r.push("## Review Summary"),r.push(`- **Verdict:** ${Fe(t.verdict)}`),r.push(`- **Sections reviewed:** ${o.length}/${i.length}`),r.push(`- **Comments:** ${e.comments.length}`);let s=i.length-o.length;r.push(`- **Skipped:** ${s} section${s===1?"":"s"} without comments`),t.summary.trim()!==""&&(r.push(""),r.push("## Overall Comments"),r.push(""),r.push(O(t.summary))),o.length>0&&(r.push(""),r.push("---"));for(let a of o){let c=De(e.comments.filter(u=>u.sectionId===a.id));if(r.push(""),r.push(`## Section ${a.id}: ${a.heading}`),r.push(""),e.mode==="plan"&&a.dependencies){let u=a.dependencies;u.dependsOn.length>0&&r.push(`Depends on: ${u.dependsOn.join(", ")}`),u.blocks.length>0&&r.push(`Blocks: ${u.blocks.join(", ")}`),r.push("")}for(let u of c){if(u.anchor){r.push("### Reviewer Comment"),r.push("");for(let l of u.anchor.lineTexts)r.push(`> ${l}`);r.push(""),r.push(O(u.text))}else r.push("### Reviewer Comment (entire section)"),r.push(""),r.push(O(u.text));r.push(""),r.push("---")}}return r.join(`
12
+ `)}import*as X from"node:readline";import w from"chalk";import{marked as Y}from"marked";import{markedTerminal as je}from"marked-terminal";import g from"chalk";Y.use(je());function q(e){let t=[];e.level===3&&e.dependencies&&(t.push(Ae(e)),t.push(""));let n=`${"#".repeat(e.level)} ${e.heading}`,i=e.body||"",o=`${n}
13
+
14
+ ${i}`;return t.push(Y.parse(o)),t.join(`
15
+ `)}function Ae(e){let t=e.dependencies,n=t.dependsOn.length>0?t.dependsOn.join(", "):"(none)",i=t.blocks.length>0?t.blocks.join(", "):"(none)",o=[`Task ${e.id}: ${e.heading}`,`\u2190 Depends on: ${n}`,`\u2192 Blocks: ${i}`];if(e.relatedFiles&&e.relatedFiles.length>0){let m=e.relatedFiles.length<=2?e.relatedFiles.join(", "):`${e.relatedFiles[0]} (+${e.relatedFiles.length-1} more)`;o.push(`Files: ${m}`)}e.verification&&o.push(`Verify: ${e.verification}`);let r=Math.max(...o.map(m=>m.length)),a=Math.min(r+4,process.stdout.columns||80)-2,c=g.dim(`\u250C${"\u2500".repeat(a)}\u2510`),u=g.dim(`\u2514${"\u2500".repeat(a)}\u2518`),l=o.map(m=>g.dim("\u2502")+" "+g.cyan(m.slice(0,a-2).padEnd(a-2))+" "+g.dim("\u2502"));return[c,...l,u].join(`
16
+ `)}function G(e){let t=[],n=new Set(e.comments.map(s=>s.sectionId));if(t.push(""),t.push(g.bold.underline(e.title)),t.push(""),e.mode==="plan"){for(let s of e.sections)if(s.level===2)t.push(g.bold.yellow(` ${s.heading}`));else if(s.level===3){let a=n.has(s.id)?g.green("\u2713"):" ";t.push(` ${a} ${g.dim(s.id)} ${s.heading}`)}}else{let s=e.sections.filter(a=>a.level>=2);for(let a=0;a<s.length;a++){let c=s[a],u=String(a+1).padStart(2),l=n.has(c.id)?g.green("\u2713"):" ";t.push(` ${l} ${g.dim(u)} ${c.heading}`)}}let i=n.size,r=e.sections.filter(s=>e.mode==="plan"?s.level===3:s.level>=2).length-i;return t.push(""),t.push(` ${g.green(`${i} section${i!==1?"s":""} commented`)} ${g.dim(`${r} remaining`)}`),t.push(""),t.join(`
17
+ `)}async function Z(e,t=!1,n){let i=t?(await import("node:fs")).createReadStream("/dev/tty"):process.stdin,o=X.createInterface({input:i,output:process.stderr}),r=c=>new Promise(u=>{o.question(c,l=>u(l.trim()))}),s=D(e),a=!0;for(;a;){console.error(G(e));let c=await r(w.cyan("> Enter section (e.g. 1.1), 'all' for linear review, or 'done' to finish: "));if(c==="done"||c==="q")a=!1;else if(c==="all")await K(e,s,r,n);else{let u=Be(e,c);if(u){let l=s.indexOf(u);await K(e,s.slice(l),r,n)}else console.error(w.red(`Section "${c}" not found. Try again.`))}}return o.close(),Ne(e),e}async function K(e,t,n,i){for(let o=0;o<t.length;o++){let r=t[o];console.error(q(r));let s=await n(w.cyan("> Comment (enter to skip, 'toc' for menu, 'back' for previous): "));if(s==="toc")return;if(s==="back"){o-=o>0?2:1;continue}else s!==""&&(e.comments.push({sectionId:r.id,text:s,timestamp:new Date}),i?.())}}function Be(e,t){let n=e.sections.find(o=>o.id===t);if(n)return n;let i=parseInt(t,10);if(!isNaN(i)){let o=D(e);if(i>=1&&i<=o.length)return o[i-1]}}function D(e){return e.sections.filter(t=>e.mode==="plan"?t.level===3:t.level>=2)}function Ne(e){let t=D(e),n=new Set(e.comments.map(i=>i.sectionId));console.error(""),console.error(w.bold("Review Summary")),console.error(` Sections: ${t.length}`),console.error(` Commented: ${w.green(String(n.size))}`),console.error(` Skipped: ${w.dim(String(t.length-n.size))}`),console.error(` Total comments: ${e.comments.length}`),console.error("")}import{execSync as Q,spawn as Me}from"node:child_process";import{writeFileSync as Le}from"node:fs";import{resolve as F}from"node:path";import v from"chalk";function ee(e,t,n={}){switch(t){case"stdout":process.stdout.write(e+`
18
+ `);break;case"clipboard":Ee(e);break;case"file":Je(e,n.outputFile,n.inputFile);break;case"claude":_e(e);break}}function Ee(e){let t=Ue(process.platform);if(!t){console.error(v.yellow("Clipboard not supported on this platform. Falling back to stdout.")),process.stdout.write(e+`
19
+ `);return}try{Q(t,{input:e,stdio:["pipe","ignore","ignore"]}),console.error(v.green("Review copied to clipboard."))}catch{console.error(v.yellow("Failed to copy to clipboard. Falling back to stdout.")),process.stdout.write(e+`
20
+ `)}}function Je(e,t,n){let i=t?F(t):n?F(n.replace(/\.md$/,".review.md")):F("review.md");try{Le(i,e,"utf-8"),console.error(v.green(`Review written to ${i}`))}catch(o){let r=o instanceof Error?o.message:String(o);console.error(v.red(`Failed to write file: ${r}`)),console.error(v.yellow("Falling back to stdout.")),process.stdout.write(e+`
21
+ `)}}function _e(e){if(!$()){console.error(v.red("Claude CLI not found in PATH.")),console.error(v.dim("Install: https://docs.anthropic.com/en/docs/claude-code")),console.error(v.yellow("Falling back to stdout.")),process.stdout.write(e+`
22
+ `);return}let t=Me("claude",[],{stdio:["pipe","inherit","inherit"]});t.stdin.write(e),t.stdin.end(),t.on("error",n=>{console.error(v.yellow(`Failed to pipe to claude: ${n.message}. Falling back to stdout.`)),process.stdout.write(e+`
23
+ `)})}function Ue(e){switch(e){case"darwin":return"pbcopy";case"linux":return"xclip -selection clipboard";case"win32":return"clip";default:return null}}function $(){try{return Q("which claude",{stdio:"ignore"}),!0}catch{return!1}}import{createRequire as dt}from"node:module";import*as j from"node:readline";import te from"chalk";async function ne(e){return e?(await import("node:fs")).createReadStream("/dev/tty"):process.stdin}async function oe(e){let t=j.createInterface({input:await ne(e),output:process.stderr}),n=await new Promise(i=>{t.question(te.cyan("> Output: (s)tdout, (c)lipboard, (f)ile, cl(a)ude? "),o=>i(o.trim().toLowerCase()))});switch(t.close(),n){case"s":case"stdout":return"stdout";case"c":case"clipboard":return"clipboard";case"f":case"file":return"file";case"a":case"claude":return"claude";default:return"stdout"}}async function ie(e,t){let n=j.createInterface({input:await ne(t),output:process.stderr}),i=await new Promise(o=>{n.question(te.yellow(`${e} (y/n) `),r=>o(r.trim().toLowerCase()))});return n.close(),i==="y"||i==="yes"}import{spawnSync as tt}from"node:child_process";import{dirname as nt}from"node:path";import{createServer as Ke}from"node:http";import{readFile as We}from"node:fs";import{join as ze,normalize as Ve,resolve as re,sep as Ye,extname as qe}from"node:path";var T=1024*1024,se={".gif":"image/gif",".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".svg":"image/svg+xml",".webp":"image/webp",".avif":"image/avif",".ico":"image/x-icon"};function ae(e){if(typeof e!="object"||e===null)return!1;let t=e;return typeof t.sectionId=="string"&&typeof t.text=="string"}function Ge(e){return e==="approved"||e===null}function ce(e){return(t,n)=>{let{method:i,url:o}=t;if(i==="GET"&&o==="/"){let r=e.getAssetHtml();n.writeHead(200,{"Content-Type":"text/html; charset=utf-8"}),n.end(r);return}if(i==="GET"&&o==="/api/doc"){let r=e.getDocument(),s={activeSection:e.getInitialActiveSection?.()??null};n.writeHead(200,{"Content-Type":"application/json"}),n.end(JSON.stringify({document:r,initialState:s}));return}if(i==="POST"&&o==="/api/review"){let r="",s=0;t.on("data",a=>{if(s+=a.length,s>T){n.writeHead(413,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"Request body too large"})),t.destroy();return}r+=a.toString()}),t.on("end",()=>{if(!(s>T))try{let a=JSON.parse(r),c=a.comments,u=a.verdict,l=a.summary;if(!Array.isArray(c)){n.writeHead(400,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"comments must be an array"}));return}if(!Ge(u)){n.writeHead(400,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:'verdict must be "approved" or null'}));return}if(typeof l!="string"){n.writeHead(400,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"summary must be a string"}));return}for(let m of c)if(!ae(m)){n.writeHead(400,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"Each comment must have sectionId (string) and text (string)"}));return}e.onSubmit({comments:c,verdict:u,summary:l}),n.writeHead(200,{"Content-Type":"application/json"}),n.end(JSON.stringify({success:!0}))}catch{n.writeHead(400,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"Invalid JSON"}))}});return}if(i==="PUT"&&o==="/api/session"){let r="",s=0;t.on("data",a=>{if(s+=a.length,s>T){n.writeHead(413,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"Request body too large"})),t.destroy();return}r+=a.toString()}),t.on("end",()=>{if(!(s>T))try{let a=JSON.parse(r),c=a.comments;if(!Array.isArray(c)){n.writeHead(400,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"comments must be an array"}));return}for(let l of c)if(!ae(l)){n.writeHead(400,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"Each comment must have sectionId (string) and text (string)"}));return}let u=typeof a.activeSection=="string"?a.activeSection:null;e.onSessionSave?.(c,u),n.writeHead(200,{"Content-Type":"application/json"}),n.end(JSON.stringify({success:!0}))}catch{n.writeHead(400,{"Content-Type":"application/json"}),n.end(JSON.stringify({error:"Invalid JSON"}))}});return}if(i==="POST"&&o==="/api/heartbeat"){e.onHeartbeat?.(),n.writeHead(204),n.end();return}if(i==="POST"&&o==="/api/pause"){e.onPause?.(),n.writeHead(204),n.end();return}if(i==="POST"&&o==="/api/cancel"){e.onCancel?.(),n.writeHead(204),n.end();return}if(i==="GET"&&o&&o.startsWith("/_assets/")){let r=e.getAssetBaseDir?.();if(!r){n.writeHead(404,{"Content-Type":"text/plain"}),n.end("No asset base directory");return}let s;try{s=decodeURIComponent(o.slice(9).split("?")[0].split("#")[0])}catch{n.writeHead(400,{"Content-Type":"text/plain"}),n.end("Bad request");return}let a=qe(s).toLowerCase();if(!se[a]){n.writeHead(415,{"Content-Type":"text/plain"}),n.end("Unsupported media type");return}let c=Ve(s),u=re(r),l=re(ze(u,c));if(!l.startsWith(u+Ye)&&l!==u){n.writeHead(403,{"Content-Type":"text/plain"}),n.end("Forbidden");return}We(l,(m,f)=>{if(m){n.writeHead(404,{"Content-Type":"text/plain"}),n.end("Not Found");return}n.writeHead(200,{"Content-Type":se[a],"Content-Length":f.length,"Cache-Control":"no-store"}),n.end(f)});return}n.writeHead(404,{"Content-Type":"text/plain"}),n.end("Not Found")}}function le(e){return Ke(ce(e))}function ue(e,t){return new Promise((n,i)=>{e.on("error",i),e.listen(t,()=>{let o=e.address(),r=typeof o=="object"&&o?o.port:t;n({url:`http://localhost:${r}`})})})}function me(e){return new Promise((t,n)=>{e.close(i=>i?n(i):t()),e.closeAllConnections()})}import{readFileSync as Xe,existsSync as de}from"node:fs";import{join as A,dirname as Ze}from"node:path";import{fileURLToPath as Qe}from"node:url";var pe=Ze(Qe(import.meta.url));function et(){let e=A(pe,"..","browser","index.html");if(de(e))return e;let t=A(pe,"..",".."),n=A(t,"dist","browser","index.html");if(de(n))return n;throw new Error(`Browser HTML not found. Run 'npm run build' first.
24
+ Looked in:
25
+ ${e}
26
+ ${n}`)}var B=null;function fe(){return B||(B=Xe(et(),"utf-8")),B}var x=class{doc=null;initialActiveSection=null;assetBaseDir=null;submitHandler=null;sessionSaveHandler=null;heartbeatHandler=null;pauseHandler=null;cancelHandler=null;server=null;sendDocument(t){this.doc=t}setInitialActiveSection(t){this.initialActiveSection=t}setAssetBaseDir(t){this.assetBaseDir=t}onReviewSubmit(t){this.submitHandler=t}onSessionSave(t){this.sessionSaveHandler=t}onHeartbeat(t){this.heartbeatHandler=t}onPause(t){this.pauseHandler=t}onCancel(t){this.cancelHandler=t}async start(t){if(!this.doc)throw new Error("No document set");return this.server=le({getDocument:()=>this.doc,getInitialActiveSection:()=>this.initialActiveSection,getAssetBaseDir:()=>this.assetBaseDir,onSubmit:n=>this.submitHandler?.(n),getAssetHtml:()=>fe(),onSessionSave:(n,i)=>this.sessionSaveHandler?.(n,i),onHeartbeat:()=>this.heartbeatHandler?.(),onPause:()=>this.pauseHandler?.(),onCancel:()=>this.cancelHandler?.()}),ue(this.server,t)}async stop(){this.server&&this.server.listening&&(await me(this.server),this.server=null)}};var ot=1800*1e3,it=30*1e3;async function he({doc:e,absPath:t,contentHash:n,restoredActiveSection:i}){let o=new x;o.sendDocument(e),o.setInitialActiveSection(i),o.setAssetBaseDir(t?nt(t):null),t&&o.onSessionSave((a,c)=>{b(t,n,a,c)});let r=new Promise((a,c)=>{let u=setTimeout(()=>c(new Error("Browser review timed out after 30 minutes of inactivity")),ot),l=null,m=()=>{l&&clearTimeout(l),l=setTimeout(()=>{clearTimeout(u),c(new Error("Review cancelled: browser closed (heartbeat lost)"))},it)},f=()=>{clearTimeout(u),l&&clearTimeout(l),l=null};o.onHeartbeat(m),o.onPause(()=>{l&&clearTimeout(l),l=null}),o.onCancel(()=>{f(),c(new Error("Review cancelled: browser closed"))}),o.onReviewSubmit(h=>{f(),a(h)})}),{url:s}=await o.start(0);process.stderr.write(`Review server running at ${s}
27
+ `);try{let a=process.platform==="darwin"?"open":process.platform==="win32"?"start":"xdg-open";tt(a,[s],{stdio:"ignore"})}catch{process.stderr.write(`Open ${s} in your browser
28
+ `)}try{return await r}finally{await o.stop()}}var pt=dt(import.meta.url),{version:ft}=pt("../package.json"),S=new rt;S.name("plan-review").description("Interactive CLI for reviewing AI-generated markdown plans").version(ft).argument("[file]","Path to markdown file (omit to read stdin)").option("-o, --output <target>","Output target: stdout, clipboard, file, claude").option("--output-file <path>","Custom output file path (with --output file)").option("--split-by <strategy>","Force split strategy: heading, separator").option("--fresh","Skip session resume, start clean review").option("--cli","Use the terminal review UI instead of the browser (SSH/CI/headless)").action(async(e,t)=>{try{await ht(e,t)}catch(n){if(n instanceof Error){let i=n.message.startsWith("Review cancelled");console.error(i?p.yellow(n.message):p.red(`Error: ${n.message}`))}process.exit(1)}});S.command("install-skill").description("Install Claude Code skill to ~/.claude/skills/plan-review/").action(()=>{let e=lt(mt(import.meta.url)),t=N(e,"..","skills","plan-review","SKILL.md");ve(t)||(console.error(p.red("Skill file not found in package. Expected at: "+t)),process.exit(1));let n=N(ct(),".claude","skills","plan-review");st(n,{recursive:!0}),at(t,N(n,"SKILL.md")),console.error(p.green(`Skill installed to ${n}/SKILL.md`)),console.error(p.dim('Claude Code will auto-discover it. Try: "I want to review this plan"'))});S.command("sessions").description("List all saved review sessions").action(()=>{let e=z(),t=y();e.length===0&&(console.error(p.dim(`No saved sessions. (${t})`)),process.exit(0)),console.error(p.bold(`Saved review sessions (${t}):
29
+ `));for(let n of e){let i=vt(n.lastModified),o="";n.stale===!0?o=p.yellow(" | plan file changed since last review"):n.stale===null&&(o=p.red(" | plan file not found")),console.error(` ${n.planPath}`),console.error(p.dim(` ${n.commentCount} comment${n.commentCount!==1?"s":""} | last modified ${i}${o}
30
+ `))}});S.parse();async function ht(e,t){let n=["stdout","clipboard","file","claude"];if(t.output!==void 0){let d=t.output;if(!n.includes(d))throw new Error(`Invalid output target: "${t.output}". Use: ${n.join(", ")}`);d==="claude"&&!$()&&(console.error(p.red("Claude CLI not found in PATH.")),console.error(p.dim("Install: https://docs.anthropic.com/en/docs/claude-code")),console.error(p.yellow("Will fall back to stdout after review.")))}let i=!e&&!process.stdin.isTTY,o=gt(e);o.trim()||(console.error(p.yellow("Empty file, nothing to review.")),process.exit(0));let r=t.splitBy==="heading"?"heading":t.splitBy==="separator"?"separator":"auto",s=L(o,r);console.error(p.dim(`Detected mode: ${s.mode} | ${s.sections.length} sections`));let a=e?ut(e):null,c=I(o),u=null;if(a)if(t.fresh)C(a);else{let d=W(a,c);d&&d.comments.length>0&&(d.stale?await ie(`Plan file changed since last review (${d.comments.length} comment${d.comments.length!==1?"s":""}). Resume anyway?`,i)?(console.error(p.yellow("Resuming with stale session.")),s.comments=d.comments,u=d.activeSection):C(a):(console.error(p.green(`Resuming review (${d.comments.length} comment${d.comments.length!==1?"s":""}).`)),s.comments=d.comments,u=d.activeSection))}let l,m={verdict:null,summary:""};if(t.cli)l=await Z(s,i,a?()=>b(a,c,s.comments,null):void 0);else{let d=await he({doc:s,absPath:a,contentHash:c,restoredActiveSection:u});s.comments=d.comments,m={verdict:d.verdict,summary:d.summary},l=s}a&&C(a);let f;t.output!==void 0?f=t.output:(f=await oe(i),f==="claude"&&!$()&&(console.error(p.red("Claude CLI not found in PATH.")),console.error(p.dim("Install: https://docs.anthropic.com/en/docs/claude-code")),console.error(p.yellow("Falling back to stdout.")),f="stdout"));let h=V(l,m);ee(h,f,{outputFile:t.outputFile,inputFile:e})}function gt(e){if(e){if(!ve(e))throw new Error(`File not found: ${e}`);return ge(e,"utf-8")}return process.stdin.isTTY?(S.help(),""):ge("/dev/stdin","utf-8")}function vt(e){let t=Date.now()-new Date(e).getTime(),n=Math.floor(t/1e3);if(n<60)return"just now";let i=Math.floor(n/60);if(i<60)return`${i}m ago`;let o=Math.floor(i/60);if(o<24)return`${o}h ago`;let r=Math.floor(o/24);return r<7?`${r}d ago`:`${Math.floor(r/7)}w ago`}
31
+ //# sourceMappingURL=index.js.map