SLOPSHOPPER

comments-review

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.

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-09rcliao/comments/mods/comments-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · comments-review
│ ┃ comments-review ✕ › fix the failing auth test and add an audit log call │ ┃ Run /review-doc <doc.md> to open a document. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /review-doc │ ⎿ comments-review: Usage: /review-doc <doc.md> — a markdown file ( │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · comments-review
Run /review-doc <doc.md> to open a document.
README

Comments

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.

asciicast

Overview

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.

Features

  • Inline comments & threads: anchored to lines or markdown sections, with nested replies and content-based re-anchoring when the doc changes
  • Edit suggestions: multi-line proposals with preview and accept/reject; queued decisions apply atomically at review verdict
  • Review gate: comments gate exits 0 (approved) or 10 (changes requested); the human's verdict in comments view is what waiting agents block on
  • Doc templates as guardrails: required sections, word caps, forced alternatives, human-owned zones, reading-depth tier 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-built
  • OKF document bundles by default: the first comments 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 search
  • RPI flow: research docs with file:line evidence → plans citing the research → reviewed in the TUI where f peeks any citation and Enter opens $EDITOR there
  • Plan-led implementation: optional in-document phase status lists keep Summary/Evidence/Next visible across multi-day work; context --for implementation reports alignment without turning Comments into the runtime
  • Autonomous research convergence: draft-blind coverage scout + evidence verifier add missing Qn questions until clean; comments analyze plan.md --against research.md proves the handoff before review
  • Watch: comments watch --until signoff streams NDJSON review events so agents can wait on humans
  • Browser review: comments serve opens a rendered document and line-accurate source view beside live threads, suggestions, and verdict controls
  • MCP server: the agent commands as tools, one per purpose and each the twin of a CLI command (new, context, validate, analyze, add, watch, inbox, get, reply, suggest, reanchor); human decisions are deliberately not tools; @filename text input
  • Surface parity: every MCP tool has a CLI equivalent backed by the same code — see docs/ARCHITECTURE.md decision 8

Why OKF and Comments fit together

Open 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.

LayerOwnsBenefit
OKF-compatible frontmatter and folderstype, title, status, provenance, relations, placementagents can discover and traverse artifacts without guessing filenames or searching the whole repository
Markdownresearch, design, plan, decision, or as-built contentthe durable artifact remains readable in any Markdown tool
.comments.json sidecaranchored threads, suggestions, verdicts, review historyagents 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.

Install

# 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

The core loop

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

TUI keys

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

What the templates produce

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.

TemplateExampleShows off
design-docdesign-doc.mdone-pager: human-written Pitch first, data flow story, full DBML model, contract interfaces
as-builtas-built.mdthe gate/signoff loop as it runs today, with peekable evidence
researchresearch.mddocumentarian findings with file:line per claim
planplan.mdphases with automated/manual success criteria
adradr.mdone decision, honest consequences
rfcrfc.mdthread citations, guide + reference level
minimini.mda 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.

Storage

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).

Documentation

License

MIT

Source 3 files
hooks/register.tsx 1092 lines
1// 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}
1092
hooks/review.ts 249 lines
1// 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}
249
types/index.d.ts 80 lines
1export 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