A side pane that lists a wipnote plan's slices and their approval state, redrawn as each slice is approved, sent back or rejected.

A Claude Code mod that keeps a wipnote plan's review state in a pane beside the chat. It replaces the dashboard's plan review: the review itself happens in the conversation (/wipnote:plan-review), and the pane shows where every slice stands while it does.
Review pane
plan-3a88d8a9 · review · 1 approved · 1 changes · 2 pending
✔ 1. Slices command
approved · trivial · promoted
↺ 2. The mod
changes · standard · M · Low
○ 3. Docs
pending · standard · S · Low
wipnote plan slices|approve-slice|reject-slice <plan-id> a Bash call runs, or with /plan-review-pane <plan-id>. /plan-review-pane with no id reopens the plan in view. prompt.md asks the model to run wipnote plan slices <plan-id> when a review starts, so the pane comes up by itself./plan-review-pane <plan-id>. The pane polls only once it is drawn.wipnote plan <sub> <plan-id> on the plan in view (approve, reject, promote, rewrite, set-slice-status, …), and every 5 s while it is drawn, so a change made in another terminal shows too.wipnote plan slices <plan-id> (JSON: number, title, complexity, effort, risk, approval_status, execution_status, feature id). It never writes to wipnote..claude-plugin/plugin.json manifest (name, types contract)
hooks/hooks.json { "modules": ["./register.tsx"] }
hooks/register.tsx hooks: session.start (command, 5 s poll), command.run, tool.call (Bash), ui.render (Pane)
hooks/model.ts pure helpers: plan id in a command, slices JSON, tally and detail lines
types/index.d.ts $.state contract (PluginState['wipnote-plan-review'])
prompt.md the one-line rule `wipnote claude` appends to the system prompt
tests/*.test.ts(x) claude plugin test suites
wipnote claude loads it with every mod in mods/; WIPNOTE_NO_MODS=1 skips the mods. On its own:
claude --plugin-dir mods/wipnote-plan-review
claude plugin validate mods/wipnote-plan-review
claude plugin test mods/wipnote-plan-review # 8 tests
tsc -p mods/wipnote-plan-review # after a load lays .claude-plugin/types/hooks/register.tsx 140 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Review } from '../types'
5import { MARK, detailLine, failReason, parseSlices, planCallIn, tallyLine } from './model'
6
7const PANE = 'wipnote-plan-review'
8const planId = atom({ plugin: 'wipnote-plan-review', key: 'planId' } as const, null)
9// Change the tag when Review's fields change: it declines what an earlier
10// version stored, which outlives a reload.
11const review = atom({ plugin: 'wipnote-plan-review', key: 'review' } as const, null, { shape: 'review-1' })
12
13const POLL_MS = 5000
14// Live only while the pane is open: show() starts the timer, and it is cancelled
15// when the pane closes (ui.close, any origin) or the session ends.
16let timer: Timer | undefined
17
18function startPoll($: EngineInterface) {
19 timer ??= $.clock.every(POLL_MS, () => {
20 void (async () => {
21 const id = await read($, planId)
22 if (id !== null) await refresh($, id)
23 })().catch(() => undefined)
24 })
25}
26
27function stopPoll() {
28 timer?.cancel()
29 timer = undefined
30}
31
32async function refresh($: EngineInterface, id: string) {
33 const ran = await $.process.run(['wipnote', 'plan', 'slices', id], { timeoutMs: 5000 }).catch(() => undefined)
34 const at = await $.clock.now()
35 let next: Review
36 if (ran === undefined) {
37 next = { state: 'failed', planId: id, reason: 'wipnote unavailable', at }
38 } else if (ran.exitCode !== 0) {
39 next = { state: 'failed', planId: id, reason: failReason(ran.stderr, ran.exitCode), at }
40 } else {
41 const parsed = parseSlices(ran.stdout)
42 next = parsed === undefined
43 ? { state: 'failed', planId: id, reason: 'unreadable output from wipnote plan slices', at }
44 : { state: 'loaded', planId: id, ...parsed, at }
45 }
46 // A plan switched while this one loaded keeps the newer one.
47 if ((await read($, planId)) === id) await update($, review, () => next)
48}
49
50// An unasked open is undrawn below 144 columns (110 once the person opened the id).
51// It polls only once placed; the first ui.render for the pane starts it later.
52async function show($: EngineInterface, id: string) {
53 await update($, planId, () => id)
54 await refresh($, id)
55 const r = await $.ui.open({ id: PANE, title: 'Plan review' })
56 if (r.isPlaced) startPoll($)
57 else $.ui.toast(`Plan review pane is waiting (${r.reason}). Type /plan-review-pane ${id} to show it.`)
58}
59
60export const register: Register = on => {
61 on('session.start', async ($, e, next) => {
62 await $.command.register({
63 name: 'plan-review-pane',
64 description: 'Show a wipnote plan\'s slices and their approval state in a side pane',
65 argumentHint: '<plan-id>',
66 })
67 return next(e)
68 })
69
70 on('ui.close', {}, async ($, e, next) => {
71 if (e.id === PANE) stopPoll()
72 return next(e)
73 })
74
75 on('session.end', async ($, e, next) => {
76 stopPoll()
77 return next(e)
78 })
79
80 on('command.run', { command: 'plan-review-pane' }, async ($, e) => {
81 const arg = e.args.trim()
82 const id = arg !== '' ? arg : await read($, planId)
83 if (id === null) return { text: 'Usage: /plan-review-pane <plan-id> (wipnote plan list shows the ids).' }
84 await show($, id)
85 return { text: `Plan review pane: ${id}.` }
86 })
87
88 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
89 const ran = await next(e)
90 const call = planCallIn(e.command)
91 if (call !== undefined) {
92 void (async () => {
93 if (call.opens) await show($, call.planId)
94 else if ((await read($, planId)) === call.planId) await refresh($, call.planId)
95 })().catch(() => undefined)
96 }
97 return ran
98 }).catch(($, e, next) => next(e))
99
100 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
101 // The first draw means the pane is placed (ui.open docs): poll from here too,
102 // for a pane that waited undrawn and was later opened or widened into view.
103 startPoll($)
104 const { Box, Text } = $.ui.resolve(e)
105 const r = await read($, review)
106 if (r === null) {
107 return (
108 <Box flexDirection="column">
109 <Text dimColor>No plan in view.</Text>
110 <Text dimColor>Run /plan-review-pane <plan-id>.</Text>
111 </Box>
112 )
113 }
114 if (r.state === 'failed') {
115 return (
116 <Box flexDirection="column">
117 <Text bold>{r.planId}</Text>
118 <Text color="red" wrap="truncate-end">{r.reason}</Text>
119 </Box>
120 )
121 }
122 // Every slice goes in the tree: the pane's body scrolls a taller one.
123 return (
124 <Box flexDirection="column">
125 <Text bold wrap="truncate-end">{r.title !== '' ? r.title : r.planId}</Text>
126 <Text dimColor wrap="truncate-end">{`${r.planId} · ${r.status} · ${tallyLine(r.slices)}`}</Text>
127 {r.slices.map(s => (
128 <Box key={`s${s.num}`} flexDirection="column">
129 <Text wrap="truncate-end">
130 <Text color={MARK[s.approval].color}>{MARK[s.approval].glyph}</Text>
131 <Text>{` ${s.num}. ${s.title}`}</Text>
132 </Text>
133 <Text dimColor wrap="truncate-end">{` ${MARK[s.approval].label} · ${detailLine(s)}`}</Text>
134 </Box>
135 ))}
136 </Box>
137 )
138 })
139}
140hooks/model.ts 87 lines1import type { Approval, SliceRow } from '../types'
2
3const PLAN_ID = 'plan-[0-9a-z]+'
4
5/** `wipnote plan <sub> <plan-id>` calls that put a plan in the pane. */
6const OPENS = new RegExp(`\\bwipnote\\s+plan\\s+(?:slices|approve-slice|reject-slice)\\s+(${PLAN_ID})\\b`, 'g')
7/** Every other `wipnote plan <sub> <plan-id>` call: it may change the plan in view. */
8const TOUCHES = new RegExp(`\\bwipnote\\s+plan\\s+[a-z-]+\\s+(${PLAN_ID})\\b`, 'g')
9
10export type PlanCall = { planId: string; opens: boolean }
11
12/** The last plan a shell command names, and whether it should open the pane. */
13export function planCallIn(command: string): PlanCall | undefined {
14 const opened = [...command.matchAll(OPENS)].map(m => m[1])
15 if (opened.length > 0) return { planId: opened[opened.length - 1]!, opens: true }
16 const touched = [...command.matchAll(TOUCHES)].map(m => m[1])
17 if (touched.length > 0) return { planId: touched[touched.length - 1]!, opens: false }
18 return undefined
19}
20
21const APPROVALS: readonly Approval[] = ['pending', 'approved', 'rejected', 'changes_requested']
22
23function text(v: unknown): string | undefined {
24 return typeof v === 'string' && v !== '' ? v : undefined
25}
26
27export type Parsed = { title: string; status: string; slices: SliceRow[] }
28
29/** Reads `wipnote plan slices` JSON; undefined when it is not that shape. */
30export function parseSlices(stdout: string): Parsed | undefined {
31 let raw: unknown
32 try {
33 raw = JSON.parse(stdout)
34 } catch {
35 return undefined
36 }
37 if (typeof raw !== 'object' || raw === null) return undefined
38 const o = raw as Record<string, unknown>
39 if (!Array.isArray(o.slices)) return undefined
40 const slices: SliceRow[] = []
41 for (const s of o.slices as Record<string, unknown>[]) {
42 if (typeof s !== 'object' || s === null || typeof s.num !== 'number') continue
43 const approval = APPROVALS.includes(s.approval_status as Approval) ? (s.approval_status as Approval) : 'pending'
44 slices.push({
45 num: s.num,
46 title: text(s.title) ?? `Slice ${s.num}`,
47 complexity: text(s.complexity) ?? 'standard',
48 effort: text(s.effort),
49 risk: text(s.risk),
50 approval,
51 execution: text(s.execution_status),
52 featureId: text(s.feature_id),
53 })
54 }
55 return { title: text(o.title) ?? '', status: text(o.status) ?? '', slices }
56}
57
58export const MARK: Record<Approval, { glyph: string; color: string; label: string }> = {
59 approved: { glyph: '✔', color: 'green', label: 'approved' },
60 changes_requested: { glyph: '↺', color: 'yellow', label: 'changes' },
61 rejected: { glyph: '✘', color: 'red', label: 'rejected' },
62 pending: { glyph: '○', color: 'gray', label: 'pending' },
63}
64
65/** "2 approved · 1 changes · 3 pending", in review order, zero counts left out. */
66export function tallyLine(slices: SliceRow[]): string {
67 const order: Approval[] = ['approved', 'changes_requested', 'rejected', 'pending']
68 const parts = order
69 .map(a => [a, slices.filter(s => s.approval === a).length] as const)
70 .filter(([, n]) => n > 0)
71 .map(([a, n]) => `${n} ${MARK[a].label}`)
72 return parts.length > 0 ? parts.join(' · ') : 'no slices'
73}
74
75/** "standard · M · Low · promoted": the slice's facts after its title. */
76export function detailLine(s: SliceRow): string {
77 return [s.complexity, s.effort, s.risk, s.execution && s.execution !== 'not_started' ? s.execution : undefined]
78 .filter((p): p is string => p !== undefined && p !== '')
79 .join(' · ')
80}
81
82/** The first line of a failed run, short enough for the pane. */
83export function failReason(stderr: string, exitCode: number): string {
84 const line = stderr.split('\n').map(l => l.trim()).find(l => l !== '')
85 return (line ?? `wipnote exited ${exitCode}`).replace(/^Error:\s*/, '').slice(0, 160)
86}
87types/index.d.ts 28 lines1export type Approval = 'pending' | 'approved' | 'rejected' | 'changes_requested'
2
3/** One slice as `wipnote plan slices <plan-id>` prints it. */
4export type SliceRow = {
5 num: number
6 title: string
7 complexity: string
8 effort?: string
9 risk?: string
10 approval: Approval
11 execution?: string
12 featureId?: string
13}
14
15/** The plan the pane shows, or why it cannot show it. */
16export type Review =
17 | { state: 'loaded'; planId: string; title: string; status: string; slices: SliceRow[]; at: number }
18 | { state: 'failed'; planId: string; reason: string; at: number }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'wipnote-plan-review': {
23 planId: string | null
24 review: Shaped<Review | null>
25 }
26 }
27}
28