Review a comments doc from Claude Code: comments view in a herdr side pane when available, else an in-Claude pane; the verdict wakes Claude.

Google-Docs-style review for markdown, locally. Inline comment threads and edit suggestions live in sidecar JSON files next to your docs — a TUI or browser workspace for the human, a CLI + MCP server for agents, and a machine-readable review gate between them.
comments is built for human↔agent doc collaboration (spec-driven development). Instead of having an LLM rewrite entire documents, the agent creates a typed knowledge artifact, drafts under a template that keeps it short and reviewable, and annotates uncertain reasoning inline. The human walks those threads in the TUI or browser and signs off; the agent listens for that verdict and addresses feedback until the gate opens.
comments gate exits 0 (approved) or 10 (changes requested); the human's verdict in comments view is what waiting agents block ontier labels (a 1-minute to full-read path over the same sections), [NEEDS CLARIFICATION:] marker caps — built-ins: design-doc, mini, research, plan, adr, rfc, as-builtcomments new initializes a standard docs/artifacts bundle, then creates frontmatter-rich concepts in template-guided folders; comments context exposes explicit relations, backlinks, sources, and review state without a whole-tree searchf peeks any citation and Enter opens $EDITOR therecontext --for implementation reports alignment without turning Comments into the runtimeQn questions until clean; comments analyze plan.md --against research.md proves the handoff before reviewcomments watch --until signoff streams NDJSON review events so agents can wait on humanscomments serve opens a rendered document and line-accurate source view beside live threads, suggestions, and verdict controlsnew, context, validate, analyze, add, watch, inbox, get, reply, suggest, reanchor); human decisions are deliberately not tools; @filename text inputdocs/ARCHITECTURE.md decision 8Open Knowledge Format (OKF) v0.2 makes a knowledge base portable: Markdown concepts carry YAML frontmatter, folders and index.md files provide navigation, and optional trust fields describe provenance and lifecycle. Comments adds the collaborative layer that the format deliberately does not prescribe.
| Layer | Owns | Benefit |
|---|---|---|
| OKF-compatible frontmatter and folders | type, title, status, provenance, relations, placement | agents can discover and traverse artifacts without guessing filenames or searching the whole repository |
| Markdown | research, design, plan, decision, or as-built content | the durable artifact remains readable in any Markdown tool |
.comments.json sidecar | anchored threads, suggestions, verdicts, review history | agents and humans can debate and approve the artifact without polluting its content or metadata |
The first comments new initializes a default bundle at docs/artifacts; no setup command is required. Comments-specific producer configuration lives in .comments/bundle.yaml, while comments.template and related extend otherwise portable OKF frontmatter. Existing Markdown remains supported and is never migrated automatically. See the OKF bundle guide for the exact boundary and format.
# the binary (required) — prebuilt, no Go toolchain needed:
# grab the archive for your platform from the latest release
# https://github.com/rcliao/comments/releases/latest
curl -sL https://github.com/rcliao/comments/releases/latest/download/comments_darwin_arm64.tar.gz | tar xz comments && mv comments ~/.local/bin/
# or, with Go installed:
go install github.com/rcliao/comments/cmd/comments@latest
# the Claude Code plugin: review-comments skill + MCP server, one install
/plugin marketplace add rcliao/comments
/plugin install comments@comments
comments new cache-policy --template design-doc
comments context docs/artifacts/designs/cache-policy.md --for drafting --include-threads
comments add docs/artifacts/designs/cache-policy.md --section "Proposed Design" \
--author agent --text "[Q] The repository does not yet establish the proposed TTL." --blocking
comments validate docs/artifacts/designs/cache-policy.md
comments watch docs/artifacts/designs/cache-policy.md --until signoff
The human reviews the same artifact while the agent listens:
comments view docs/artifacts/designs/cache-policy.md # q -> a/c/r records a verdict
comments serve docs/artifacts/designs/cache-policy.md # browser alternative
After the signoff event, the agent reads comments inbox docs/artifacts/designs/cache-policy.md first, fixes or replies to each thread, and checks comments gate (exit 0 = approved, 10 = changes requested). The verdict is recorded only by the human, in comments view or comments serve — no command writes one, so an agent cannot approve its own document.
For Research → Plan, use the same slug and preserve lineage:
comments new cache-policy --template research-deep
comments new cache-policy --template plan --from docs/artifacts/research/cache-policy.md
comments analyze docs/artifacts/plans/cache-policy.md \
--against docs/artifacts/research/cache-policy.md --json
j/k move · r dive into thread at cursor · Tab cycle stacked threads · n/N next/prev NEW since your last signoff · f peek citation (Enter → $EDITOR at file:line) · t table of contents · a/x queue accept/reject on suggestions · q verdict (approve / request changes, n for a review note) · ? full keybinding help
Every template ships with a self-describing, OKF-compatible worked example under docs/examples/ — real subjects from this repo, written to every constraint and validating clean. These are static examples; comments new places live artifacts in the configured bundle.
| Template | Example | Shows off |
|---|---|---|
design-doc | design-doc.md | one-pager: human-written Pitch first, data flow story, full DBML model, contract interfaces |
as-built | as-built.md | the gate/signoff loop as it runs today, with peekable evidence |
research | research.md | documentarian findings with file:line per claim |
plan | plan.md | phases with automated/manual success criteria |
adr | adr.md | one decision, honest consequences |
rfc | rfc.md | thread citations, guide + reference level |
mini | mini.md | a whole change in 400 words |
Review any of them in the tool itself: comments view docs/examples/design-doc.md — peek the citations with f.
The knowledge bundle and the review record are intentionally separate:
.comments/bundle.yaml maps templates to typed folders and generates navigational indexes;docs/artifacts/**/*.md contains portable OKF-compatible concepts;doc.md.comments.json contains collaboration state beside each reviewed concept.Sidecars keep Markdown clean, version collaboration independently, and use a SHA-256 document hash to drive the re-anchoring cascade (exact → text → fuzzy → section → orphan).
MIT
hooks/register.tsx 1092 lines1// comments-review: the `comments view` review loop in a Claude Code pane.
2//
3// The pane is a client of `comments serve`, the existing browser review
4// surface: it spawns the server, keeps the bootstrap token in this module's
5// memory (never in the transcript, never in $.state), and sends the same
6// /api/action requests the browser does. Every mutation, the verdict
7// included, therefore runs through the code paths that already guard the
8// human surfaces (zone: human resolve guard, revision conflicts, RecordVerdict
9// semantics); this mod adds no new way to write a sidecar.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { ReviewGate, ReviewMode, ReviewSnapshot, ReviewThread, ReviewView } from '../types'
14import {
15 INITIAL_VIEW,
16 isOpen,
17 markerFor,
18 moveCursor,
19 nextThreadLine,
20 parseServeUrl,
21 placeCursor,
22 threadsAt,
23 toSnapshot,
24 verdictPrompt,
25 watchTarget,
26 allowedDuringReview,
27 type ServeEndpoint,
28 isHandoffFront,
29 driftStatus,
30 frontOf,
31 isLivingFront,
32 LIVING_MARK,
33 livingNote,
34 type LivingState,
35} from './review'
36
37const PANE = 'comments-review'
38const view = atom({ plugin: 'comments-review', key: 'view' } as const, INITIAL_VIEW)
39const snapshot = atom({ plugin: 'comments-review', key: 'snapshot' } as const, null as ReviewSnapshot | null)
40// The plan whose approval gates code edits, and a plan the user unlocked by
41// hand. Kept in $.state so a reload of the mod cannot silently open the gate.
42const activePlan = atom({ plugin: 'comments-review', key: 'activePlan' } as const, null as string | null)
43const unlockedPlan = atom({ plugin: 'comments-review', key: 'unlockedPlan' } as const, null as string | null)
44// Whether this session was already reminded of the contract. session.start
45// fires again on every hot reload of the mod; $.state outlives a reload but
46// not the process, so this keeps the reminder to once per session.
47// The session's living doc and the code edits made since it last changed.
48const livingDoc = atom({ plugin: 'comments-review', key: 'livingDoc' } as const, null as string | null)
49const drift = atom({ plugin: 'comments-review', key: 'drift' } as const, 0)
50const reminded = atom({ plugin: 'comments-review', key: 'reminded' } as const, false)
51
52// The tallest the thread panel grows: header, body and a few replies.
53const THREAD_ROWS = 6
54
55type Server = { endpoint: ServeEndpoint | null; stop: () => void; doc: string }
56
57let binary = 'comments'
58let server: Server | null = null
59let docRows = 20
60
61function setView($: EngineInterface, fn: (v: ReviewView) => ReviewView) {
62 return update($, view, fn)
63}
64function say($: EngineInterface, message: string) {
65 return setView($, v => ({ ...v, message }))
66}
67
68async function api($: EngineInterface, path: string, body?: object): Promise<{ status: number; json: unknown }> {
69 if (!server?.endpoint) throw new Error('review server is not running')
70 const { base, token } = server.endpoint
71 const doc = encodeURIComponent((await read($, snapshot))?.docId ?? '')
72 const res = await $.http.fetch(`${base}${path}?doc=${doc}`, {
73 method: body ? 'POST' : 'GET',
74 headers: { Cookie: `comments_review_token=${token}`, 'Content-Type': 'application/json' },
75 body: body ? JSON.stringify(body) : undefined,
76 })
77 let json: unknown = null
78 try {
79 json = JSON.parse(res.text)
80 } catch {
81 json = { error: res.text.trim() }
82 }
83 return { status: res.status, json }
84}
85
86async function refresh($: EngineInterface) {
87 const { status, json } = await api($, '/api/state')
88 if (status !== 200) return say($, `refresh failed: ${errorOf(json)}`)
89 const next = toSnapshot(json as Parameters<typeof toSnapshot>[0])
90 const prev = await read($, snapshot)
91 if (prev?.revision === next.revision) return
92 await update($, snapshot, () => next)
93 await setView($, v => placeCursor(v, v.cursor, next.lines.length, docRows))
94}
95
96// Sends one review action; a 409 means the agent edited the doc meanwhile,
97// so the refreshed state replaces ours and the person retries knowingly.
98async function act($: EngineInterface, body: Record<string, unknown>, done: string): Promise<boolean> {
99 const snap = await read($, snapshot)
100 if (!snap) return false
101 try {
102 const { status, json } = await api($, '/api/action', { ...body, revision: snap.revision })
103 if (status === 409) {
104 const state = (json as { state?: Parameters<typeof toSnapshot>[0] }).state
105 if (state) await update($, snapshot, () => toSnapshot(state))
106 await say($, 'The document changed underneath you; refreshed. Check the line and try again.')
107 return false
108 }
109 if (status !== 200) {
110 await say($, `${String(body.action)} failed: ${errorOf(json)}`)
111 return false
112 }
113 await update($, snapshot, () => toSnapshot(json as Parameters<typeof toSnapshot>[0]))
114 await say($, done)
115 return true
116 } catch (err) {
117 await say($, `${String(body.action)} failed: ${String(err)}`)
118 return false
119 }
120}
121
122// What the supervisor should serve; /review-doc sets it, closing the pane clears it.
123let wanted: string | null = null
124let supervising = false
125let wake: () => void = () => undefined
126let waiters: ((ok: boolean) => void)[] = []
127
128function settle(ok: boolean) {
129 const pending = waiters
130 waiters = []
131 for (const resolve of pending) resolve(ok)
132}
133
134function request(doc: string | null) {
135 wanted = doc
136 server?.stop()
137 wake()
138}
139
140// Owns `comments serve` for the session. It is started from session.start
141// because a child spawned inside a command's dispatch belongs to that
142// dispatch: /review-doc would never finish, and finishing would kill the server.
143async function supervise($: EngineInterface) {
144 supervising = true
145 for (;;) {
146 const doc = wanted
147 if (doc === null) {
148 await new Promise<void>(resolve => (wake = resolve))
149 continue
150 }
151 await serveOnce($, doc)
152 }
153}
154
155async function serveOnce($: EngineInterface, doc: string) {
156 const stream = $.process.spawn({ argv: [binary, 'serve', doc] })
157 const mine: Server = { endpoint: null, doc, stop: () => void stream.return?.(undefined as never) }
158 server = mine
159 let out = ''
160 let err = ''
161 try {
162 for await (const chunk of stream) {
163 if (chunk.stream === 'stderr') err += chunk.text
164 else out += chunk.text
165 if (!mine.endpoint) {
166 mine.endpoint = parseServeUrl(out)
167 if (mine.endpoint) settle(true)
168 }
169 }
170 } catch (e) {
171 err += String(e)
172 }
173 if (server === mine) server = null
174 // Exited on its own rather than replaced or closed: report it and idle.
175 if (wanted === doc) {
176 wanted = null
177 settle(false)
178 // Best effort: the module may already be unloading (reload, session end).
179 await setView($, v => ({ ...v, status: 'error', message: `comments serve exited: ${(err || out).trim().slice(0, 300)}` })).catch(
180 () => undefined,
181 )
182 }
183}
184
185// Live refresh: the agent may reply or edit while the pane is open. Paused
186// while the person composes, so a redraw never fights their typing.
187async function poll($: EngineInterface) {
188 const v = await read($, view)
189 if (server?.endpoint && v.status === 'ready' && (v.mode === 'browse' || v.mode === 'verdict')) {
190 await refresh($).catch(() => undefined)
191 }
192}
193
194async function openReview($: EngineInterface, doc: string) {
195 await update($, snapshot, () => null)
196 await setView($, () => ({ ...INITIAL_VIEW, doc, status: 'starting', message: `Starting comments serve for ${doc}…` }))
197 await focusPane($, doc)
198 if (!supervising) {
199 await setView($, v => ({ ...v, status: 'error', message: 'The review supervisor is not running; restart the session.' }))
200 return
201 }
202 const ok = await new Promise<boolean>(resolve => {
203 waiters.push(resolve)
204 request(doc)
205 })
206 if (!ok) return
207 await refresh($)
208 await setView($, v => ({ ...v, status: 'ready', message: 'Esc hands the keys back to Claude Code (its rule, not ours); click the pane or run /review-doc to return.' }))
209}
210
211// The person asked for the pane, so it takes the keyboard (granted only over
212// an empty composer) and opens tall enough inline to read a passage. Another
213// plugin's band above the prompt would otherwise catch ctrl+x tab first.
214async function focusPane($: EngineInterface, doc: string) {
215 await $.ui.open({ id: PANE, title: `Review · ${doc}`, focus: true, rows: 34, columns: 100 })
216}
217
218// --- herdr: the real `comments view` beside Claude --------------------------
219//
220// Inside a herdr pane, /review-doc opens `comments view` in a sibling pane:
221// the full TUI at full height, Esc behaving as it does there, and the verdict
222// recorded by the human surface that already owns it (no token, no server).
223// A `comments watch --until signoff` loop, owned by session.start for the same
224// reason the server is, wakes Claude when the reviewer exits with a verdict.
225// While a review is open the doc is locked against Claude's Edit/Write, and a
226// review pane that closes without a verdict ends the wait instead of hanging it.
227
228let surface: 'auto' | 'herdr' | 'pane' = 'auto'
229let sessionCwd = '.'
230
231type WatchJob = { doc: string; since: string; pane: string | null; plan: boolean; stop: () => void; closed: boolean }
232let watched: WatchJob | null = null
233let watchWake: () => void = () => undefined
234
235async function herdrPane($: EngineInterface): Promise<string | null> {
236 if (surface === 'pane') return null
237 if ((await $.env.get('HERDR_ENV')) !== '1') return null
238 return (await $.env.get('HERDR_PANE_ID')) ?? null
239}
240
241function shellQuote(s: string): string {
242 return `'${s.replace(/'/g, `'\\''`)}'`
243}
244
245function absolute(path: string): string {
246 return path.startsWith('/') ? path : `${sessionCwd.replace(/\/$/, '')}/${path.replace(/^\.\//, '')}`
247}
248
249// Splits Claude's herdr pane to the right and runs the TUI there; the pane
250// closes itself when the reviewer quits. Never guesses a pane id: a split
251// that returns none is an error, not a `pane list` diff (revdiff's rule).
252async function openInHerdr($: EngineInterface, caller: string, doc: string): Promise<string> {
253 const split = await $.process.run(['herdr', 'pane', 'split', '--pane', caller, '--direction', 'right', '--cwd', sessionCwd, '--focus'])
254 if (split.exitCode !== 0) throw new Error((split.stderr || split.stdout).trim() || 'herdr pane split failed')
255 let id: string | undefined
256 try {
257 id = (JSON.parse(split.stdout) as { result?: { pane?: { pane_id?: string } } }).result?.pane?.pane_id
258 } catch {
259 id = undefined
260 }
261 if (!id || id === caller) throw new Error('herdr pane split returned no usable pane id')
262 const ran = await $.process.run(['herdr', 'pane', 'run', id, `${shellQuote(binary)} view ${shellQuote(doc)}; exit`])
263 if (ran.exitCode !== 0) throw new Error((ran.stderr || ran.stdout).trim() || 'herdr pane run failed')
264 return id
265}
266
267async function superviseWatch($: EngineInterface) {
268 for (;;) {
269 const job = watched
270 if (job === null) {
271 await new Promise<void>(resolve => (watchWake = resolve))
272 continue
273 }
274 await watchOnce($, job)
275 }
276}
277
278type Signoff = { event: string; author?: string; decision?: string; note?: string }
279
280async function watchOnce($: EngineInterface, job: WatchJob) {
281 const stream = $.process.spawn({ argv: [binary, 'watch', job.doc, '--until', 'signoff', '--since', job.since] })
282 job.stop = () => void stream.return?.(undefined as never)
283 let buffer = ''
284 let signoff: Signoff | null = null
285 try {
286 for await (const chunk of stream) {
287 if (chunk.stream !== 'stdout') continue
288 buffer += chunk.text
289 const lines = buffer.split('\n')
290 buffer = lines.pop() ?? ''
291 for (const line of lines) {
292 try {
293 const event = JSON.parse(line) as Signoff
294 if (event.event === 'signoff') signoff = event
295 } catch {
296 // not an event line
297 }
298 }
299 }
300 } catch {
301 // the watcher could not start or was stopped; handled below
302 }
303 if (watched !== job) return
304 watched = null
305 if (handoffPending === job.doc) handoffPending = null
306 $.ui.status(undefined)
307 if (!signoff) {
308 $.ui.toast(
309 job.closed
310 ? `The review of ${job.doc} closed without a verdict; run /review-doc ${job.doc} to reopen it.`
311 : `comments watch stopped before a verdict on ${job.doc}`,
312 )
313 return
314 }
315 const gate = await gateOf($, job.doc)
316 const decision = signoff.decision ?? 'commented'
317 const note = signoff.note ?? ''
318 if (job.plan) return deliverPlanVerdict($, job.doc, decision, note, gate)
319 await $.prompt.submit({ text: verdictPrompt(job.doc, decision, note, gate) })
320}
321
322// The pane closing is the reviewer leaving: a `comments view` quit records
323// its verdict before it exits, so give the watcher a moment to see it, then
324// stop waiting rather than hold the session on a review nobody can finish.
325async function checkReviewPane($: EngineInterface) {
326 const job = watched
327 if (!job?.pane || job.closed) return
328 const got = await $.process.run(['herdr', 'pane', 'get', job.pane]).catch(() => null)
329 if (!got || got.exitCode === 0 || !/pane_not_found/.test(got.stdout + got.stderr)) return
330 job.closed = true
331 await $.clock.sleep(2500)
332 if (watched === job) job.stop()
333}
334
335async function gateOf($: EngineInterface, doc: string): Promise<ReviewGate | undefined> {
336 const res = await $.process.run([binary, 'gate', doc, '--json'])
337 try {
338 const g = JSON.parse(res.stdout) as { decision: string; summary: { blocking: number; non_blocking: number; pending_suggestions: number } }
339 return { decision: g.decision, blocking: g.summary.blocking, nonBlocking: g.summary.non_blocking, pendingSuggestions: g.summary.pending_suggestions }
340 } catch {
341 return undefined
342 }
343}
344
345async function startWatch($: EngineInterface, doc: string, pane: string | null, plan: boolean) {
346 const since = new Date(await $.clock.now()).toISOString()
347 watched?.stop()
348 watched = { doc, since, pane, plan, stop: () => undefined, closed: false }
349 watchWake()
350}
351
352async function reviewInHerdr($: EngineInterface, caller: string, doc: string, plan = false): Promise<string> {
353 const pane = await openInHerdr($, caller, doc)
354 await startWatch($, doc, pane, plan)
355 $.ui.status(`comments: ${plan ? 'plan' : doc} under review in herdr`)
356 return `Opened comments view for ${doc} in a herdr pane on the right. Quit it with a verdict (q, then a/c/r) and it comes back here.`
357}
358
359// #3: while a doc is under review, Claude's file tools may not change it, so
360// the reviewer's view never shifts under them. Bash is not covered (best effort).
361function lockedDoc(path: unknown): string | null {
362 if (typeof path !== 'string') return null
363 const open = [watched?.doc, planReview?.doc].filter((d): d is string => !!d)
364 return open.find(d => absolute(d) === absolute(path)) ?? null
365}
366
367// --- hand-off: Claude's `comments watch --until signoff` on a plan --------
368//
369// The review skill hands a doc to the human with a blocking
370// `comments watch <doc> --until signoff`. For a `plan` doc inside herdr the
371// mod makes that a non-blocking hand-off: review opens beside the session,
372// the call is answered at once, and until the verdict Claude's non-read-only
373// tools are refused, so ending the turn is its only move. Other docs, and
374// sessions outside herdr, keep the blocking watch as written.
375
376let handoffPending: string | null = null
377
378async function isPlanDoc($: EngineInterface, doc: string): Promise<boolean> {
379 try {
380 const front = /^---\n([\s\S]*?)\n---/.exec(await $.fs.read(absolute(doc)))?.[1] ?? ''
381 return isHandoffFront(front)
382 } catch {
383 return false
384 }
385}
386
387async function handOff($: EngineInterface, doc: string): Promise<string | null> {
388 if (!(await isPlanDoc($, doc))) return null
389 const caller = await herdrPane($)
390 if (!caller) return null
391 try {
392 await reviewInHerdr($, caller, doc)
393 } catch {
394 return null
395 }
396 handoffPending = doc
397 await activatePlan($, doc)
398 return `Hand-off done: ${doc} is open for review beside the user in comments view; the comments-review mod replaced the blocking watch. Do not retry the watch and do not keep working: end your turn now. The verdict arrives as a message from comments-review, and tools other than reads are paused until then.`
399}
400
401// --- Phase 2: the approved plan is the contract for code edits ------------
402//
403// While a plan is active, Claude's Edit/Write outside it are refused unless
404// the latest verdict is approved, the gate passes, and the approval's intent
405// hash is current (`context --for implementation`: Status lists may change,
406// nothing else). Any failure keeps edits locked; only the user's own
407// `/review-doc --unlock` (origin `composer`) overrides.
408
409type Contract = { state: 'unlocked' | 'locked' | 'error'; reason: string }
410let contractCache: { doc: string; at: number; value: Contract } | null = null
411
412async function activatePlan($: EngineInterface, doc: string) {
413 await update($, activePlan, () => doc)
414 // Best effort: a store failure must not cost the hand-off itself.
415 await $.store.set(`activePlan:${sessionCwd}`, doc).catch(() => undefined)
416 await update($, unlockedPlan, u => (u === doc ? u : null))
417 contractCache = null
418}
419
420async function contractOf($: EngineInterface, doc: string): Promise<Contract> {
421 const now = await $.clock.now()
422 if (contractCache?.doc === doc && now - contractCache.at < 3000) return contractCache.value
423 let value: Contract
424 try {
425 const ctx = JSON.parse((await $.process.run([binary, 'context', doc, '--for', 'implementation', '--json'])).stdout) as {
426 implementation?: { approval?: { decision?: string; freshness?: string } }
427 }
428 const gate = JSON.parse((await $.process.run([binary, 'gate', doc, '--json'])).stdout) as { decision?: string }
429 const a = ctx.implementation?.approval
430 if (a?.decision === 'approved' && a.freshness === 'current' && gate.decision === 'approved') {
431 value = { state: 'unlocked', reason: 'approved and current' }
432 } else if (a?.decision !== 'approved') {
433 value = { state: 'locked', reason: 'not approved yet' }
434 } else if (a.freshness !== 'current') {
435 value = { state: 'locked', reason: `approval is ${a.freshness ?? 'unknown'}: the plan changed since` }
436 } else {
437 value = { state: 'locked', reason: `gate is ${gate.decision ?? 'unknown'}` }
438 }
439 } catch (err) {
440 value = { state: 'error', reason: `contract check failed: ${String(err).slice(0, 120)}` }
441 }
442 contractCache = { doc, at: now, value }
443 return value
444}
445
446async function showContract($: EngineInterface) {
447 const doc = await read($, activePlan)
448 if (!doc && !watched) {
449 const living = await read($, livingDoc)
450 if (living) $.ui.status(driftStatus(living, await read($, drift), await livingState($, living).catch(() => null)))
451 return
452 }
453 if (!doc || watched) return
454 const name = doc.replace(/^.*\//, '')
455 if ((await read($, unlockedPlan)) === doc) return $.ui.status(`plan ${name}: unlocked by you`)
456 const c = await contractOf($, doc)
457 $.ui.status(c.state === 'unlocked' ? `plan ${name}: approved, edits open` : `plan ${name}: edits locked (${c.reason})`)
458}
459
460async function contractDeny($: EngineInterface, path: unknown, content?: unknown): Promise<string | null> {
461 const doc = await read($, activePlan)
462 if (!doc) return null
463 if (typeof path === 'string' && absolute(path) === absolute(doc)) return null
464 // A living doc is markdown the agent keeps current; it is never code.
465 if (typeof path === 'string' && (await livingTarget($, absolute(path), content))) return null
466 if ((await read($, unlockedPlan)) === doc) return null
467 const c = await contractOf($, doc)
468 if (c.state === 'unlocked') return null
469 return `Code edits wait for the plan contract: ${doc} (${c.reason}). Revise the plan and hand it off with \`comments watch ${doc} --until signoff\`, then end your turn. Only the user can override with /review-doc --unlock.`
470}
471
472// --- Phase 3: the contract survives compaction and resume ------------------
473//
474// The note carries only gate-derived state (plan path, gate, lock, blocking
475// count), never thread text or plan prose, so a summary cannot re-inject a
476// reviewer's words as instructions (adversarial-review-loop's rule).
477
478const CONTRACT_MARK = '[comments contract]'
479
480async function contractNote($: EngineInterface): Promise<string | null> {
481 const doc = await read($, activePlan)
482 if (!doc) return null
483 const c = (await read($, unlockedPlan)) === doc ? { state: 'unlocked', reason: 'unlocked by the user' } : await contractOf($, doc)
484 const gate = await gateOf($, doc)
485 const edits = c.state === 'unlocked' ? `open (${c.reason})` : `locked (${c.reason})`
486 const blocking = gate ? `${gate.decision}, ${gate.blocking} blocking` : 'unknown'
487 const picks = await openPicks($, doc).catch(() => null)
488 const pickLine = picks === null ? '' : ` ${picks} open pick${picks === 1 ? '' : 's'} for the end review.`
489 return `${CONTRACT_MARK} Active plan: ${doc}. Gate: ${blocking}. Code edits: ${edits}.${pickLine} Implement only what the plan says; decide alone where you can and file a pick (\`comments add --pick\`) instead of asking; run \`comments context ${doc} --for implementation\` for phases and status.`
490}
491
492// With no plan active, the living doc's note stands in for the contract's.
493async function sessionNote($: EngineInterface): Promise<string | null> {
494 const note = await contractNote($).catch(() => null)
495 if (note) return note
496 const living = await read($, livingDoc)
497 return living ? livingNote(living, await read($, drift)) : null
498}
499
500// --- living docs: the doc and the build move together ----------------------
501//
502// Writing a doc whose frontmatter says `template: living` makes it the
503// session's living doc; every other file edit counts as drift until the doc
504// changes again. The count is a nudge in the status line and the session
505// note, never a lock.
506
507async function livingTarget($: EngineInterface, path: string, content: unknown): Promise<boolean> {
508 if (!/\.md$/.test(path)) return false
509 if (typeof content === 'string') return isLivingFront(frontOf(content))
510 try {
511 return isLivingFront(frontOf(await $.fs.read(path)))
512 } catch {
513 return false
514 }
515}
516
517async function trackEdit($: EngineInterface, path: unknown, content: unknown) {
518 if (typeof path !== 'string') return
519 const abs = absolute(path)
520 if (await livingTarget($, abs, content)) {
521 await update($, livingDoc, () => abs)
522 await update($, drift, () => 0)
523 await $.store.set(`livingDoc:${sessionCwd}`, abs).catch(() => undefined)
524 } else if (await read($, livingDoc)) {
525 await update($, drift, n => n + 1)
526 } else {
527 return
528 }
529 // The count outlives a restart, so a resumed session never reads "current"
530 // for a doc the build has run ahead of.
531 await $.store.set(`drift:${sessionCwd}`, String(await read($, drift))).catch(() => undefined)
532 await showContract($).catch(() => undefined)
533}
534
535// The doc's phase and Now, read through the binary (`inbox --json` carries
536// them), so the mod has no parser of its own to drift from the Go one.
537async function livingState($: EngineInterface, doc: string): Promise<LivingState | null> {
538 const inbox = JSON.parse((await $.process.run([binary, 'inbox', doc, '--json'])).stdout) as { files?: { living?: LivingState }[] }
539 return inbox.files?.[0]?.living ?? null
540}
541
542async function restoreLivingDoc($: EngineInterface) {
543 if (await read($, livingDoc)) return
544 const saved = await $.store.get(`livingDoc:${sessionCwd}`)
545 if (typeof saved !== 'string' || saved === '') return
546 await update($, livingDoc, () => saved)
547 const count = Number(await $.store.get(`drift:${sessionCwd}`))
548 await update($, drift, () => (Number.isFinite(count) && count > 0 ? count : 0))
549}
550
551async function clearLivingDoc($: EngineInterface): Promise<string | null> {
552 const doc = await read($, livingDoc)
553 await update($, livingDoc, () => null)
554 await update($, drift, () => 0)
555 await $.store.set(`livingDoc:${sessionCwd}`, '').catch(() => undefined)
556 await $.store.set(`drift:${sessionCwd}`, '0').catch(() => undefined)
557 $.ui.status('')
558 return doc
559}
560
561// Picks the agent filed and the human has not settled yet.
562async function openPicks($: EngineInterface, doc: string): Promise<number> {
563 const inbox = JSON.parse((await $.process.run([binary, 'inbox', doc, '--json'])).stdout) as { items?: { thread?: { pick?: string } }[] }
564 return (inbox.items ?? []).filter(i => !!i.thread?.pick).length
565}
566
567// True the first time it is asked in a session, false after, reloads included.
568async function claimReminder($: EngineInterface): Promise<boolean> {
569 if (await read($, reminded)) return false
570 await update($, reminded, () => true)
571 return true
572}
573
574async function restoreActivePlan($: EngineInterface) {
575 if (await read($, activePlan)) return
576 const saved = await $.store.get(`activePlan:${sessionCwd}`)
577 if (typeof saved === 'string' && saved !== '') await update($, activePlan, () => saved)
578}
579
580// --- plan mode → comments review (Plannotator's loop, comments as surface) --
581//
582// Claude's ExitPlanMode is denied while the plan is saved as a comments doc
583// and opened for review; the verdict arrives as a plugin turn. A revised plan
584// (ExitPlanMode again) rewrites the same doc, so threads re-anchor and carry
585// over between rounds. After approval with a passing gate, Claude's next
586// ExitPlanMode is allowed with the reviewed doc as the plan, accepted
587// suggestions included. Subagents keep Claude Code's own flow.
588
589type PlanReview = { doc: string; version: number; status: 'reviewing' | 'approved'; passing: string | null }
590let planReview: PlanReview | null = null
591
592function planSlug(plan: string): string {
593 const heading = /^#\s+(.+)$/m.exec(plan)?.[1] ?? 'plan'
594 return heading.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48) || 'plan'
595}
596
597async function planText($: EngineInterface, e: { plan?: unknown; planFilePath?: unknown }): Promise<string> {
598 const inline = typeof e.plan === 'string' ? e.plan : ''
599 const path = e.planFilePath
600 if (typeof path === 'string' && path.startsWith('/') && /\.md$/i.test(path)) {
601 try {
602 return (await $.fs.read(path)) || inline
603 } catch {
604 return inline
605 }
606 }
607 return inline
608}
609
610async function writePlanDoc($: EngineInterface, plan: string, doc?: string): Promise<string> {
611 const target = doc ?? `docs/artifacts/plans/${new Date(await $.clock.now()).toISOString().slice(0, 10)}-${planSlug(plan)}.md`
612 await $.process.run(['mkdir', '-p', absolute(target).replace(/\/[^/]+$/, '')])
613 await $.fs.write(absolute(target), plan.endsWith('\n') ? plan : `${plan}\n`)
614 return target
615}
616
617async function onPlanCall($: EngineInterface, toolUseId: string, input: { plan?: unknown; planFilePath?: unknown }): Promise<string | null> {
618 if (planReview?.status === 'approved') {
619 planReview.passing = toolUseId
620 return null
621 }
622 const plan = await planText($, input)
623 if (plan.trim() === '') return null
624 const caller = await herdrPane($)
625 if (planReview) {
626 planReview.version += 1
627 await writePlanDoc($, plan, planReview.doc)
628 // `comments view` reloads the file only before a write, so an open pane
629 // would show the old text: close it and reopen on the rewritten doc.
630 // Submitted comments are already saved; only an unsent draft is lost.
631 const open = watched?.doc === planReview.doc ? watched : null
632 if (open?.pane) {
633 open.closed = true
634 open.stop()
635 await $.process.run(['herdr', 'pane', 'close', open.pane]).catch(() => null)
636 }
637 const reopened = await reopenPlanReview($, caller, planReview.doc)
638 return `Plan v${planReview.version} rewrote ${planReview.doc}; its review threads carried over.${reopened} It is NOT approved. Stay in plan mode and end your turn; the verdict arrives as a message from comments-review.`
639 }
640 const doc = await writePlanDoc($, plan)
641 await activatePlan($, doc)
642 planReview = { doc, version: 1, status: 'reviewing', passing: null }
643 const opened = await reopenPlanReview($, caller, doc)
644 return `Plan v1 saved to ${doc}.${opened} It is NOT approved. Stay in plan mode and do not implement; end your turn. The verdict arrives as a message from comments-review; to revise, call ExitPlanMode again.`
645}
646
647async function reopenPlanReview($: EngineInterface, caller: string | null, doc: string): Promise<string> {
648 if (caller) {
649 try {
650 await reviewInHerdr($, caller, doc, true)
651 return ' It is open for review in comments view beside you.'
652 } catch (err) {
653 await startWatch($, doc, null, true)
654 return ` The herdr pane did not open (${String(err)}); the user reviews it with \`comments view ${doc}\`.`
655 }
656 }
657 await startWatch($, doc, null, true)
658 $.ui.status('comments: plan waiting for review')
659 return ` The user reviews it with \`comments view ${doc}\` (or /review-doc ${doc}).`
660}
661
662async function deliverPlanVerdict($: EngineInterface, doc: string, decision: string, note: string, gate: ReviewGate | undefined) {
663 const said = note.trim() === '' ? '' : ` Reviewer note: "${note.trim()}".`
664 if (planReview?.doc === doc && decision === 'approved' && gate?.decision === 'approved') {
665 planReview.status = 'approved'
666 await $.prompt.submit({
667 text: `The plan in ${doc} was approved in comments review and its gate passes.${said} Run \`comments inbox ${doc} --json\` for any last replies, then call ExitPlanMode again; the reviewed doc (accepted suggestions included) becomes the plan you implement.`,
668 })
669 return
670 }
671 const gateLine = gate ? ` The gate is ${gate.decision} with ${gate.blocking} blocking thread(s) and ${gate.pendingSuggestions} pending suggestion(s).` : ''
672 await $.prompt.submit({
673 text: `Plan review of ${doc}: ${decision.replace('_', ' ')}.${gateLine}${said} Stay in plan mode. Run \`comments inbox ${doc} --json\`, reply on each thread with \`comments reply\` (do not resolve the human's threads), then call ExitPlanMode with the revised plan; it rewrites ${doc} and the threads carry over.`,
674 })
675}
676
677
678export const register: Register = (on, options) => {
679 binary = typeof options.binary === 'string' && options.binary !== '' ? options.binary : 'comments'
680 surface = options.surface === 'herdr' || options.surface === 'pane' ? options.surface : 'auto'
681
682 on('session.start', async ($, e, next) => {
683 await $.command.register({
684 name: 'review-doc',
685 description: 'Review a comments doc in a pane (comment, reply, resolve, suggestions, verdict)',
686 argumentHint: '<doc.md> | --unlock | --park | --done',
687 })
688 sessionCwd = e.cwd
689 await restoreActivePlan($).catch(() => undefined)
690 await restoreLivingDoc($).catch(() => undefined)
691 void supervise($)
692 void superviseWatch($)
693 // Live refresh: the agent may reply or edit while the pane is open.
694 $.clock.every(2000, () => void poll($))
695 $.clock.every(3000, () => void checkReviewPane($))
696 $.clock.every(5000, () => void showContract($).catch(() => undefined))
697 // A reload drops the old module's child; reopen against the doc on record.
698 const v = await read($, view)
699 if (v.doc !== '' && v.status !== 'idle') void openReview($, v.doc)
700 const started = await next(e)
701 // On a resumed or restarted session, remind Claude of the contract once.
702 const note = await sessionNote($).catch(() => null)
703 if (note && (await claimReminder($).catch(() => false))) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } }).catch(() => undefined)
704 return started
705 })
706
707 // #1: plan mode hands its plan to comments review instead of the dialog.
708 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
709 const call = e as unknown as { tool_use_id: string; agentId?: string; plan?: unknown; planFilePath?: unknown }
710 if (call.agentId) return next(e)
711 const deny = await onPlanCall($, call.tool_use_id, call)
712 return deny === null ? next(e) : { deny }
713 }).catch(($, e, next) => next(e)) // fail open: Claude Code's own plan dialog
714
715 // The approved plan's second ExitPlanMode: allow it with the reviewed doc as
716 // the plan. Answered without next(), so no other review opens for it.
717 on('classic.PermissionRequest', async ($, e, next) => {
718 const req = e as unknown as { tool_name?: string; agent_id?: string; tool_use_id?: string; tool_input?: Record<string, unknown> }
719 const review = planReview
720 if (req.tool_name !== 'ExitPlanMode' || req.agent_id || review?.status !== 'approved' || review.passing !== req.tool_use_id) return next(e)
721 const plan = await $.fs.read(absolute(review.doc))
722 planReview = null
723 if (watched?.doc === review.doc) watched.stop()
724 $.ui.status(undefined)
725 return { decision: { behavior: 'allow', updatedInput: { ...(req.tool_input ?? {}), plan } } } as never
726 }).catch(($, e, next) => next(e))
727
728 // Phase 1: a plan's blocking hand-off becomes a non-blocking review, and
729 // until the verdict only reads go through. Subagents are left alone.
730 on('tool.call', async ($, e, next) => {
731 const call = e as unknown as { tool: string; command?: unknown; agentId?: string }
732 if (call.agentId) return next(e)
733 if (call.tool === 'Bash' && typeof call.command === 'string') {
734 const doc = watchTarget(call.command)
735 if (doc) {
736 const handed = await handOff($, doc)
737 if (handed) return { deny: handed }
738 }
739 }
740 if (handoffPending && !allowedDuringReview(call.tool, call.command)) {
741 return { deny: `${handoffPending} is waiting for the user's review, so only reads run until the verdict. End your turn; the verdict arrives as a message from comments-review.` }
742 }
743 return next(e)
744 }).catch(($, e, next) => next(e))
745
746 // Phase 2: code edits outside the active plan wait for its approval. Fails
747 // closed: a broken check keeps edits locked.
748 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
749 const args = e as unknown as { file_path?: unknown; notebook_path?: unknown; content?: unknown }
750 const deny = await contractDeny($, args.file_path ?? args.notebook_path, args.content)
751 return deny === null ? next(e) : { deny }
752 }).catch(() => ({ deny: 'The plan contract check failed, so code edits stay locked. Only the user can override with /review-doc --unlock.' }))
753
754 // Living docs: track which file changed after the edit lands. Main session only.
755 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
756 const args = e as unknown as { file_path?: unknown; notebook_path?: unknown; content?: unknown; agentId?: string }
757 const result = await next(e)
758 if (!args.agentId && !JSON.stringify(result).includes('"deny"')) await trackEdit($, args.file_path ?? args.notebook_path, args.content).catch(() => undefined)
759 return result
760 }).catch(($, e, next) => next(e))
761
762 // #3: no Edit/Write to a doc while it is under review.
763 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
764 const args = e as unknown as { file_path?: unknown; notebook_path?: unknown }
765 const doc = lockedDoc(args.file_path ?? args.notebook_path)
766 if (!doc) return next(e)
767 return {
768 deny: `${doc} is under human review in comments view, so it is locked until the verdict arrives. Reply on threads with \`comments reply\`${planReview?.doc === doc ? ', or revise the plan by calling ExitPlanMode again' : ''}.`,
769 }
770 }).catch(($, e, next) => next(e))
771
772 // Phase 3: compaction keeps exactly one fresh contract note.
773 on('session.compact', async ($, e, next) => {
774 if ((e as unknown as { agentId?: string }).agentId !== undefined) return next(e)
775 const note = await sessionNote($).catch(() => null)
776 if (!note) return next(e)
777 const result = await next(e)
778 if (result.skip !== undefined) return result
779 const kept = result.messages.filter(m => !(m.text ?? '').startsWith(CONTRACT_MARK) && !(m.text ?? '').startsWith(LIVING_MARK))
780 return { ...result, messages: [...kept, { role: 'user' as const, text: note, toolUses: [] }] }
781 }).catch(($, e, next) => next(e))
782
783 on('session.end', async ($, e, next) => {
784 request(null)
785 return next(e)
786 })
787
788 on('command.run', { command: 'review-doc' }, async ($, e) => {
789 // `--unlock` opens the active plan's gate by hand; only the person's own
790 // Enter (an engine-stamped `composer` origin) may run it.
791 if (/(^|\s)--unlock(\s|$)/.test(e.args)) {
792 if (e.origin?.kind !== 'composer') return { text: '/review-doc --unlock runs only from your own Enter at the prompt.' }
793 const doc = await read($, activePlan)
794 if (!doc) return { text: 'No plan is active, so there is nothing to unlock.' }
795 await update($, unlockedPlan, () => doc)
796 await showContract($)
797 return { text: `Unlocked code edits for ${doc} by hand. The contract check is bypassed until another plan is handed off.` }
798 }
799 // `--park` sets the active plan aside: no lock, no contract note, until a
800 // plan is handed off again. The person's own Enter only, like --unlock.
801 if (/(^|\s)--park(\s|$)/.test(e.args)) {
802 if (e.origin?.kind !== 'composer') return { text: '/review-doc --park runs only from your own Enter at the prompt.' }
803 const doc = await read($, activePlan)
804 if (!doc) return { text: 'No plan is active, so there is nothing to park.' }
805 await update($, activePlan, () => null)
806 await update($, unlockedPlan, () => null)
807 await $.store.set(`activePlan:${sessionCwd}`, '').catch(() => undefined)
808 contractCache = null
809 await showContract($)
810 return { text: `Parked ${doc}: code edits are no longer gated on it. Handing a plan off makes it active again.` }
811 }
812 // `--done` ends the living doc for this repo: no note, no drift count,
813 // until a living doc is written again.
814 if (/(^|\s)--done(\s|$)/.test(e.args)) {
815 const doc = await clearLivingDoc($)
816 return { text: doc ? `Done with ${doc}: no living doc is tracked now.` : 'No living doc is tracked.' }
817 }
818 // `--pane` forces the in-Claude pane even inside herdr.
819 const forcePane = /(^|\s)--pane(\s|$)/.test(e.args)
820 const doc = e.args.replace(/(^|\s)--pane(\s|$)/, ' ').trim()
821 const caller = forcePane || doc === '' ? null : await herdrPane($)
822 if (caller) {
823 try {
824 return { text: await reviewInHerdr($, caller, doc) }
825 } catch (err) {
826 if (surface === 'herdr') return { text: `Could not open a herdr pane: ${String(err)}` }
827 // auto: fall through to the in-Claude pane
828 }
829 }
830 if (doc === '') {
831 const open = await read($, view)
832 if (open.doc === '' || open.status === 'idle') {
833 return { text: 'Usage: /review-doc <doc.md> — a markdown file (a sidecar is created if needed).' }
834 }
835 await focusPane($, open.doc)
836 return { text: `Back in the review pane for ${open.doc}.` }
837 }
838 await openReview($, doc)
839 const v = await read($, view)
840 // Only the fullscreen layout docks a pane beside the transcript (from 110
841 // columns); the main screen always seats it inline, capped in height.
842 const where = e.presentation?.isFullscreen
843 ? e.presentation.columns < 110 ? ' It is inline: the side dock needs a terminal 110+ columns wide.' : ''
844 : ' It opened inline because this session uses the default layout; run /tui fullscreen to dock it on the side at full height.'
845 return { text: v.status === 'error' ? v.message : `Opened the review pane for ${doc}.${where}` }
846 })
847
848 on('ui.close', { id: PANE }, async ($, e, next) => {
849 request(null)
850 await setView($, () => INITIAL_VIEW)
851 return next(e)
852 }).catch(($, e, next) => next(e))
853
854 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
855 if (e.surface === 'mobile') {
856 const { Text } = $.ui.resolve(e)
857 return <Text>The review pane needs a surface with text input; use comments serve on mobile.</Text>
858 }
859 const { Box, Text, Button, Input } = $.ui.resolve(e)
860 const v = await read($, view)
861 const snap = await read($, snapshot)
862 const width = Math.max(30, e.props.bodyColumns)
863 const rows = e.props.scroll?.bodyRows ?? 30
864
865 // Zoom keeps every hotkey (a hotkey only works while its Button is drawn)
866 // but shrinks each to a glyph, so the document gets nearly every row.
867 const btn = (hotkey: string, label: string, onPress: () => unknown, primary = false, glyph = '') => (
868 <Button key={`k-${hotkey}`} plain hotkey={hotkey} variant={primary ? 'primary' : undefined} onPress={() => onPress()}>
869 {v.zoom ? glyph : label}
870 </Button>
871 )
872
873 if (!snap) {
874 return (
875 <Box flexDirection="column">
876 <Text>{v.message || 'Run /review-doc <doc.md> to open a document.'}</Text>
877 </Box>
878 )
879 }
880
881 const lineCount = snap.lines.length
882 const here = threadsAt(snap, v.cursor, v.showResolved)
883 const thread: ReviewThread | undefined = here[Math.min(v.threadIndex, Math.max(0, here.length - 1))]
884 const open = snap.threads.filter(isOpen).length
885 const last = snap.reviews[snap.reviews.length - 1]
886 const gutter = String(lineCount).length
887
888 // Every row not spent on chrome goes to the document: the thread panel is
889 // only as tall as the thread under the cursor, the message row appears
890 // only with a message, and zoom folds both to a single line.
891 const composing = v.mode === 'comment' || v.mode === 'blocking' || v.mode === 'reply' || v.mode === 'note'
892 const threadBody = thread ? (thread.isSuggestion ? 3 : 2) + thread.replies.length : 1
893 const threadRows = v.zoom && !composing ? 1 : Math.min(THREAD_ROWS, threadBody)
894 const messageRows = v.message !== '' && !v.zoom ? 1 : 0
895 const controlRows = v.zoom && !composing ? 1 : 2
896 docRows = Math.max(3, rows - 1 - threadRows - messageRows - controlRows)
897
898 const move = (delta: number) => setView($, x => moveCursor(x, delta, lineCount, docRows))
899 const jump = (line: number) => setView($, x => placeCursor(x, line, lineCount, docRows))
900 const mode = (m: ReviewMode, message = '') => setView($, x => ({ ...x, mode: m, message }))
901
902 const header = (
903 <Box flexDirection="row" justifyContent="space-between">
904 <Text bold wrap="truncate-end">
905 {snap.name}
906 {e.props.isFocused ? '' : <Text dimColor> (not focused: /review-doc)</Text>}
907 </Text>
908 <Text color={snap.gate.decision === 'approved' ? 'green' : 'yellow'}>
909 {snap.gate.decision.replace('_', ' ')} · {snap.gate.blocking} blocking · {open} open · {snap.gate.pendingSuggestions} sugg
910 </Text>
911 </Box>
912 )
913
914 const docWindow = (
915 <Box flexDirection="column" height={docRows}>
916 {snap.lines.slice(v.top - 1, v.top - 1 + docRows).map((text, i) => {
917 const n = v.top + i
918 const mark = markerFor(snap, n, v.showResolved)
919 const isCursor = n === v.cursor
920 return (
921 <Text key={`l-${n}`} inverse={isCursor} wrap="truncate-end">
922 <Text color={mark === '!' ? 'red' : mark === '±' ? 'cyan' : 'yellow'}>{mark}</Text>
923 <Text dimColor={!isCursor}> {String(n).padStart(gutter)} </Text>
924 {text === '' ? ' ' : text.replace(/\t/g, ' ')}
925 </Text>
926 )
927 })}
928 </Box>
929 )
930
931 const threadPanel = threadRows === 1 && thread ? (
932 <Text wrap="truncate-end">
933 <Text bold color={thread.blocking ? 'red' : undefined}>{thread.id}</Text> {thread.author}: {thread.isSuggestion ? `± ${thread.proposedText.split('\n')[0]}` : thread.text}
934 {thread.replies.length ? ` (+${thread.replies.length})` : ''}
935 </Text>
936 ) : (
937 <Box flexDirection="column" height={threadRows}>
938 {thread ? (
939 <Box flexDirection="column">
940 <Text wrap="truncate-end" bold>
941 {thread.blocking ? 'BLOCKING ' : ''}
942 {thread.isSuggestion ? 'Suggestion' : 'Thread'} {thread.id} · {thread.author}
943 {thread.resolved ? ' · resolved' : ''}
944 {here.length > 1 ? ` · ${v.threadIndex + 1}/${here.length} (t cycles)` : ''}
945 </Text>
946 {thread.isSuggestion ? (
947 <>
948 <Text wrap="truncate-end" color="red">- {thread.originalText.split('\n')[0]}</Text>
949 <Text wrap="truncate-end" color="green">+ {thread.proposedText.split('\n')[0]}</Text>
950 </>
951 ) : (
952 <Text wrap="truncate-end">{thread.text}</Text>
953 )}
954 {thread.replies.slice(-Math.max(0, threadRows - (thread.isSuggestion ? 3 : 2))).map(r => (
955 <Text key={`r-${r.id}`} wrap="truncate-end" dimColor>
956 ↳ {r.author}: {r.text}
957 </Text>
958 ))}
959 </Box>
960 ) : (
961 <Text dimColor wrap="truncate-end">
962 No thread on line {v.cursor}.{last ? ` Last verdict: ${last.decision} by ${last.author}.` : ''}
963 </Text>
964 )}
965 </Box>
966 )
967
968 let controls
969 if (v.mode === 'comment' || v.mode === 'blocking' || v.mode === 'reply' || v.mode === 'note') {
970 const label =
971 v.mode === 'reply' ? `Reply to ${thread?.id ?? '?'}: ` :
972 v.mode === 'note' ? 'Verdict note: ' :
973 `${v.mode === 'blocking' ? 'Blocking comment' : 'Comment'} on L${v.cursor}: `
974 // Esc belongs to Claude Code (it always hands the keys back to the
975 // prompt and never reaches a plugin), so an empty Enter is the cancel.
976 const submit = async (text: string) => {
977 if (v.mode === 'note') {
978 await setView($, x => ({ ...x, note: text, mode: 'verdict', message: text.trim() ? 'Note saved; pick a decision.' : '' }))
979 return
980 }
981 if (text.trim() === '') return mode('browse', 'Cancelled.')
982 const ok =
983 v.mode === 'reply'
984 ? await act($, { action: 'reply', thread_id: thread?.id ?? '', text }, `Replied to ${thread?.id}.`)
985 : await act($, { action: 'add', line: v.cursor, text, blocking: v.mode === 'blocking' }, `Added a comment on line ${v.cursor}.`)
986 if (ok) await mode('browse', (await read($, view)).message)
987 }
988 controls = (
989 <Box flexDirection="column">
990 <Input key="compose" label={label} autoFocus submitLabel="send" value={v.mode === 'note' ? v.note : ''} onSubmit={text => submit(text)} />
991 <Box flexDirection="row" gap={2}>
992 <Button key="cancel" plain onPress={() => mode(v.mode === 'note' ? 'verdict' : 'browse')}>Cancel</Button>
993 <Text dimColor>Enter sends · empty Enter cancels · Esc leaves the pane (text kept; click or /review-doc to return)</Text>
994 </Box>
995 </Box>
996 )
997 } else if (v.mode === 'verdict') {
998 const record = async (decision: string) => {
999 const note = v.note
1000 const ok = await act($, { action: 'verdict', decision, note }, `Recorded ${decision.replace('_', ' ')}.`)
1001 if (!ok) return
1002 await setView($, x => ({ ...x, mode: 'browse', note: '' }))
1003 // Hand the turn back to Claude: the verdict is the envelope, the
1004 // threads are the payload (review-comments skill, inbox first).
1005 const gate = (await read($, snapshot))?.gate
1006 await $.prompt.submit({ text: verdictPrompt(v.doc, decision, note, gate) })
1007 }
1008 controls = (
1009 <Box flexDirection="column">
1010 <Text>Submit review{v.note ? ` — note: "${v.note}"` : ''}</Text>
1011 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1012 {btn('a', 'approve', () => record('approved'), true)}
1013 {btn('c', 'request changes', () => record('changes_requested'))}
1014 {btn('r', 'replies only', () => record('commented'))}
1015 {btn('n', 'note', () => mode('note'))}
1016 {btn('q', 'back', () => mode('browse'))}
1017 </Box>
1018 </Box>
1019 )
1020 } else {
1021 const needThread = (f: (t: ReviewThread) => unknown) => () => (thread ? f(thread) : say($, `No thread on line ${v.cursor}.`))
1022 const nav = [
1023 btn('j', 'down', () => move(1), false, '↓'),
1024 btn('k', 'up', () => move(-1), false, '↑'),
1025 btn('d', 'pg dn', () => move(Math.max(1, Math.floor(docRows / 2))), false, '⇟'),
1026 btn('u', 'pg up', () => move(-Math.max(1, Math.floor(docRows / 2))), false, '⇞'),
1027 btn('g', 'top', () => jump(1), false, '⤒'),
1028 btn('e', 'end', () => jump(lineCount), false, '⤓'),
1029 btn('n', 'next thread', () => {
1030 const l = nextThreadLine(snap, v.cursor, 1, v.showResolved)
1031 return l === null ? say($, 'No threads.') : jump(l)
1032 }, false, '▸'),
1033 btn('p', 'prev thread', () => {
1034 const l = nextThreadLine(snap, v.cursor, -1, v.showResolved)
1035 return l === null ? say($, 'No threads.') : jump(l)
1036 }, false, '◂'),
1037 btn('t', 'cycle', () => setView($, x => ({ ...x, threadIndex: here.length ? (x.threadIndex + 1) % here.length : 0 })), false, '↻'),
1038 ]
1039 const edit = [
1040 btn('c', 'comment', () => mode('comment'), false, '+'),
1041 btn('b', 'blocking', () => mode('blocking'), false, '!'),
1042 btn('r', 'reply', needThread(() => mode('reply')), false, '↩'),
1043 btn('s', thread?.resolved ? 'reopen' : 'resolve', needThread(t =>
1044 act($, { action: t.resolved ? 'reopen' : 'resolve', thread_id: t.id }, `${t.resolved ? 'Reopened' : 'Resolved'} ${t.id}.`),
1045 ), false, '✓'),
1046 btn('y', 'accept', needThread(t =>
1047 t.isSuggestion ? act($, { action: 'accept', thread_id: t.id }, `Accepted ${t.id}; the document was edited.`) : say($, 'Not a suggestion.'),
1048 ), false, '±'),
1049 btn('x', 'reject', needThread(t =>
1050 t.isSuggestion ? act($, { action: 'reject', thread_id: t.id }, `Rejected ${t.id}.`) : say($, 'Not a suggestion.'),
1051 ), false, '✗'),
1052 btn('h', v.showResolved ? 'hide resolved' : 'show resolved', () => setView($, x => ({ ...x, showResolved: !x.showResolved })), false, '◌'),
1053 btn('z', v.zoom ? 'unzoom' : 'zoom', () => setView($, x => ({ ...x, zoom: !x.zoom })), false, '⊡'),
1054 btn('v', 'submit review', () => mode('verdict'), true, 'review'),
1055 ]
1056 controls = v.zoom ? (
1057 <Box flexDirection="row" columnGap={1} overflow="hidden">
1058 {[...nav, ...edit]}
1059 </Box>
1060 ) : (
1061 <Box flexDirection="column">
1062 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1063 {nav}
1064 </Box>
1065 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1066 {edit}
1067 </Box>
1068 </Box>
1069 )
1070 }
1071
1072 return (
1073 <Box flexDirection="column" width={width}>
1074 {header}
1075 {docWindow}
1076 {threadPanel}
1077 {messageRows > 0 && (
1078 <Text color={v.status === 'error' ? 'red' : undefined} dimColor={v.status !== 'error'} wrap="truncate-end">
1079 {v.message}
1080 </Text>
1081 )}
1082 {controls}
1083 </Box>
1084 )
1085 })
1086}
1087
1088function errorOf(json: unknown): string {
1089 if (json && typeof json === 'object' && 'error' in json) return String((json as { error: unknown }).error)
1090 return JSON.stringify(json).slice(0, 200)
1091}
1092hooks/review.ts 249 lines1// Pure review logic: no `$`, so it is testable on its own and the hooks
2// module stays about wiring and drawing.
3import type { ReviewGate, ReviewSnapshot, ReviewThread, ReviewView } from '../types'
4
5export const INITIAL_VIEW: ReviewView = {
6 doc: '',
7 status: 'idle',
8 cursor: 1,
9 top: 1,
10 threadIndex: 0,
11 mode: 'browse',
12 note: '',
13 message: '',
14 showResolved: false,
15 zoom: false,
16}
17
18export type ServeEndpoint = { base: string; token: string }
19
20// `comments serve` prints `Open: http://127.0.0.1:PORT/?token=HEX`. The token
21// stays in the hooks module's memory; it is never shown or sent to the model.
22export function parseServeUrl(output: string): ServeEndpoint | null {
23 const match = /Open: (http:\/\/[^\s/]+)\/\?token=([0-9a-f]+)/.exec(output)
24 const [, base, token] = match ?? []
25 return base && token ? { base, token } : null
26}
27
28type ApiComment = {
29 id: string
30 author: string
31 line: number
32 text: string
33 type?: string
34 blocking: boolean
35 resolved: boolean
36 is_suggestion?: boolean
37 start_line?: number
38 end_line?: number
39 original_text?: string
40 proposed_text?: string
41 accepted?: boolean | null
42 section_path?: string
43 orphaned_reason?: string
44 replies?: ApiComment[]
45}
46
47type ApiState = {
48 doc_id: string
49 name: string
50 author: string
51 revision: string
52 lines: string[]
53 document: {
54 threads: ApiComment[] | null
55 reviews?: { author: string; decision: string; note?: string }[] | null
56 }
57 gate: { decision: string; blocking: number; non_blocking: number; pending_suggestions: number }
58}
59
60// Converts /api/state into the snapshot the pane draws: only what it shows,
61// and every optional field filled so drawing code never branches on absence.
62export function toSnapshot(state: ApiState): ReviewSnapshot {
63 const threads: ReviewThread[] = (state.document.threads ?? []).map(c => ({
64 id: c.id,
65 author: c.author,
66 line: c.line,
67 text: c.text,
68 type: c.type ?? '',
69 blocking: c.blocking,
70 resolved: c.resolved,
71 isSuggestion: c.is_suggestion ?? false,
72 startLine: c.start_line ?? c.line,
73 endLine: c.end_line ?? c.line,
74 originalText: c.original_text ?? '',
75 proposedText: c.proposed_text ?? '',
76 accepted: c.accepted ?? null,
77 sectionPath: c.section_path ?? '',
78 orphaned: (c.orphaned_reason ?? '') !== '',
79 replies: (c.replies ?? []).map(r => ({ id: r.id, author: r.author, text: r.text })),
80 }))
81 return {
82 docId: state.doc_id,
83 name: state.name,
84 author: state.author,
85 revision: state.revision,
86 lines: state.lines,
87 threads,
88 gate: {
89 decision: state.gate.decision,
90 blocking: state.gate.blocking,
91 nonBlocking: state.gate.non_blocking,
92 pendingSuggestions: state.gate.pending_suggestions,
93 },
94 reviews: (state.document.reviews ?? []).map(r => ({
95 author: r.author,
96 decision: r.decision,
97 note: r.note ?? '',
98 })),
99 }
100}
101
102export function isOpen(thread: ReviewThread): boolean {
103 if (thread.isSuggestion) return thread.accepted === null && !thread.resolved
104 return !thread.resolved
105}
106
107function visible(thread: ReviewThread, showResolved: boolean): boolean {
108 return showResolved || isOpen(thread)
109}
110
111function covers(thread: ReviewThread, line: number): boolean {
112 if (thread.isSuggestion) return line >= thread.startLine && line <= thread.endLine
113 return thread.line === line
114}
115
116// Threads anchored at `line`, blocking first, the order the TUI lists them.
117export function threadsAt(snapshot: ReviewSnapshot, line: number, showResolved: boolean): ReviewThread[] {
118 return snapshot.threads
119 .filter(t => visible(t, showResolved) && covers(t, line))
120 .sort((a, b) => Number(b.blocking) - Number(a.blocking))
121}
122
123// One gutter glyph per line: blocking beats suggestion beats comment beats
124// resolved, so the most urgent thread on a line is the one you see.
125export function markerFor(snapshot: ReviewSnapshot, line: number, showResolved: boolean): string {
126 const here = snapshot.threads.filter(t => covers(t, line))
127 if (here.some(t => isOpen(t) && t.blocking)) return '!'
128 if (here.some(t => isOpen(t) && t.isSuggestion)) return '±'
129 if (here.some(t => isOpen(t))) return '●'
130 if (showResolved && here.length > 0) return '✓'
131 return ' '
132}
133
134// Moves the cursor by `delta` lines and scrolls so it stays in a window of
135// `height` rows.
136export function moveCursor(view: ReviewView, delta: number, lineCount: number, height: number): ReviewView {
137 return placeCursor(view, view.cursor + delta, lineCount, height)
138}
139
140export function placeCursor(view: ReviewView, line: number, lineCount: number, height: number): ReviewView {
141 const cursor = Math.min(Math.max(1, line), Math.max(1, lineCount))
142 let top = view.top
143 if (cursor < top) top = cursor
144 if (cursor >= top + height) top = cursor - height + 1
145 top = Math.max(1, Math.min(top, Math.max(1, lineCount - height + 1)))
146 return { ...view, cursor, top, threadIndex: 0 }
147}
148
149// The next (dir 1) or previous (dir -1) line holding a visible thread,
150// wrapping around the document; null when there is none.
151export function nextThreadLine(snapshot: ReviewSnapshot, from: number, dir: 1 | -1, showResolved: boolean): number | null {
152 const lines = [
153 ...new Set(
154 snapshot.threads
155 .filter(t => visible(t, showResolved))
156 .map(t => (t.isSuggestion ? t.startLine : t.line)),
157 ),
158 ].sort((a, b) => a - b)
159 if (dir === 1) return lines.find(l => l > from) ?? lines[0] ?? null
160 return [...lines].reverse().find(l => l < from) ?? lines[lines.length - 1] ?? null
161}
162
163export function verdictPrompt(doc: string, decision: string, note: string, gate?: ReviewGate): string {
164 const said = note.trim() === '' ? '' : ` Reviewer note: "${note.trim()}".`
165 // The verdict is not the gate: blocking threads still fail it, so say so
166 // rather than letting "approved" read as "proceed".
167 const stillBlocked = gate !== undefined && gate.decision !== 'approved'
168 if (decision === 'approved' && !stillBlocked) {
169 return `I approved the review of ${doc} in comments review.${said} Run \`comments inbox ${doc} --json\` for any remaining replies, then proceed.`
170 }
171 if (decision === 'approved') {
172 return `I approved the review of ${doc} in comments review, but the gate is still ${gate?.decision} with ${gate?.blocking} blocking thread(s).${said} Run \`comments inbox ${doc} --json\`, work through the blocking threads, and do not treat the doc as cleared until \`comments gate\` passes.`
173 }
174 const label = decision === 'changes_requested' ? 'requested changes on' : 'replied to threads on'
175 return `I ${label} ${doc} in comments review.${said} Run \`comments inbox ${doc} --json\` and process each thread per the review-comments skill.`
176}
177
178// The doc a `comments watch ... --until <events incl. signoff>` names, if any.
179export function watchTarget(command: string): string | null {
180 const at = command.search(/(^|[\s;&|(])comments\s+watch\s/)
181 if (at < 0) return null
182 const rest = command.slice(at).replace(/^[\s;&|(]*comments\s+watch\s+/, '').split(/[;&|)]/)[0] ?? ''
183 const words = rest.trim().split(/\s+/).map(w => w.replace(/^['"]|['"]$/g, ''))
184 let doc: string | null = null
185 let signoff = false
186 for (let i = 0; i < words.length; i++) {
187 const w = words[i] ?? ''
188 if (w === '--until') {
189 signoff = /(^|,)signoff(,|$)/.test(words[i + 1] ?? '')
190 i++
191 } else if (w.startsWith('--until=')) {
192 signoff = /(^|,)signoff(,|$)/.test(w.slice(8))
193 } else if (w === '--since' || w === '--interval') {
194 i++
195 } else if (!w.startsWith('-') && doc === null) {
196 doc = w
197 }
198 }
199 return signoff && doc ? doc : null
200}
201
202const READ_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'ToolSearch', 'WebFetch', 'WebSearch'])
203const READ_ONLY_BASH = /^\s*(comments\s+(inbox|get|gate|context|validate|analyze)\b|git\s+(status|diff|log|show)\b|(cat|ls|rg|grep|head|tail|wc)\s)/
204
205// While a hand-off is pending, only reads go through.
206export function allowedDuringReview(tool: string, command: unknown): boolean {
207 if (READ_ONLY_TOOLS.has(tool)) return true
208 if (/comments_(inbox|get|context|gate)$/.test(tool)) return true
209 return tool === 'Bash' && typeof command === 'string' && READ_ONLY_BASH.test(command) && !/[;&|>]/.test(command)
210}
211
212// Whether a doc's frontmatter makes it a hand-off artifact: a plan, or a
213// brief (the tiered successor). Research and design docs only support one.
214export function isHandoffFront(front: string): boolean {
215 return /^\s*template:\s*(plan|brief)\s*$/m.test(front)
216}
217
218// Whether a doc's frontmatter makes it a living doc: kept current by the agent
219// while it builds, never handed off, never a lock on code edits.
220export function isLivingFront(front: string): boolean {
221 return /^\s*template:\s*living\s*$/m.test(front)
222}
223
224export function frontOf(text: string): string {
225 return /^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? ''
226}
227
228export const LIVING_MARK = '[comments living doc]'
229
230export type LivingState = { phase?: string; now?: string }
231
232// The status line for the living doc: how far the build has run ahead of it,
233// then the doc's own recap (phase and Now's first line), so it sits beside
234// Claude Code's recap and comes from the doc.
235export function driftStatus(doc: string, edits: number, state?: LivingState | null): string {
236 const name = doc.replace(/^.*\//, '')
237 const drift = edits === 0 ? `doc ${name}: current` : `doc ${name}: ${edits} code edit${edits === 1 ? '' : 's'} since the doc`
238 const phase = state?.phase ? ` · ${state.phase}` : ''
239 const now = state?.now ? ` · ${state.now.length > 80 ? state.now.slice(0, 79) + '…' : state.now}` : ''
240 return drift + phase + now
241}
242
243// The note re-injected after compaction or a restart. Path and a count only,
244// never doc prose, so a summary cannot replay the doc's words as instructions.
245export function livingNote(doc: string, edits: number): string {
246 const drift = edits === 0 ? 'It is current with the code.' : `${edits} code edit${edits === 1 ? '' : 's'} since it last changed.`
247 return `${LIVING_MARK} Living doc: ${doc}. ${drift} Update its Now in the same turn as the work, and give every decision made in chat a line in Decisions.`
248}
249types/index.d.ts 80 lines1export type ReviewReply = {
2 id: string
3 author: string
4 text: string
5}
6
7export type ReviewThread = {
8 id: string
9 author: string
10 line: number
11 text: string
12 type: string
13 blocking: boolean
14 resolved: boolean
15 isSuggestion: boolean
16 startLine: number
17 endLine: number
18 originalText: string
19 proposedText: string
20 accepted: boolean | null
21 sectionPath: string
22 orphaned: boolean
23 replies: ReviewReply[]
24}
25
26export type ReviewGate = {
27 decision: string
28 blocking: number
29 nonBlocking: number
30 pendingSuggestions: number
31}
32
33export type ReviewRecord = {
34 author: string
35 decision: string
36 note: string
37}
38
39export type ReviewSnapshot = {
40 docId: string
41 name: string
42 author: string
43 revision: string
44 lines: string[]
45 threads: ReviewThread[]
46 gate: ReviewGate
47 reviews: ReviewRecord[]
48}
49
50export type ReviewMode = 'browse' | 'comment' | 'blocking' | 'reply' | 'verdict' | 'note'
51
52export type ReviewStatus = 'idle' | 'starting' | 'ready' | 'error'
53
54export type ReviewView = {
55 doc: string
56 status: ReviewStatus
57 cursor: number
58 top: number
59 threadIndex: number
60 mode: ReviewMode
61 note: string
62 message: string
63 showResolved: boolean
64 zoom: boolean
65}
66
67declare module 'claude-code' {
68 interface PluginState {
69 'comments-review': {
70 view: ReviewView
71 snapshot: ReviewSnapshot | null
72 activePlan: string | null
73 unlockedPlan: string | null
74 reminded: boolean
75 livingDoc: string | null
76 drift: number
77 }
78 }
79}
80