SLOPSHOPPER

wipnote-plan-review

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

newpaneguardcommandtoastprocess
★ 4v0.1.1MITupdated 2026-10-25shakestzd/wipnote/mods/wipnote-plan-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wipnote-plan-review
│ ┃ wipnote-plan-review ✕ › fix the failing auth test and add an audit log call │ ┃ No plan in view. │ ┃ Run /plan-review-pane <plan-id>. ⏺ 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 │ │ › /plan-review-pane │ ⎿ wipnote-plan-review: Usage: /plan-review-pane <plan-id> (wipnote │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · wipnote-plan-review
No plan in view. Run /plan-review-pane <plan-id>.
README

wipnote-plan-review (mod)

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

What it does

  • Opens on the first 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.
  • Waits when Claude Code cannot draw the pane. An unasked open is drawn only from 144 columns (110 once you have opened the pane by hand). Below that a toast names the reason and says to type /plan-review-pane <plan-id>. The pane polls only once it is drawn.
  • Redraws after each Bash call that runs 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.
  • Reads wipnote plan slices <plan-id> (JSON: number, title, complexity, effort, risk, approval_status, execution_status, feature id). It never writes to wipnote.
  • A failed read shows the CLI's first error line in the pane.

Files

.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

Run it

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

Check it

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/
Source 3 files
hooks/register.tsx 140 lines
1import { 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 &lt;plan-id&gt;.</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}
140
hooks/model.ts 87 lines
1import 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}
87
types/index.d.ts 28 lines
1export 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