SLOPSHOPPER

handover-report

/handover-report: a pane with the state of .handovers/handover_log.md — open handovers, their branches, open questions, stale worktrees

newpanecommandprocess
v0.1.0no licenseupdated 2026-10-04bzatrok/ClaudeMods/handover-report
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handover-report
│ ┃ Handovers ✕ › fix the failing auth test and add an audit log call │ ┃ app · log on develop, fix: refresh expired … │ ┃ ⏺ Read(src/auth.ts) │ ┃ Open 0 ⎿ Read 6 lines │ ┃ Nothing open. ⏺ Update(src/auth.ts) │ ┃ 0 done ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Open questions 0 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Repository ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ checkout feat/auth-refresh · 2 uncommitted │ ┃ develop vs mainline 0 ahead, 0 behind ✻ Worked for 42s · done 4:20 PM │ ┃ worktrees 0 │ › /handover-report │ ⎿ handover-report: Handover report for app (log on develop, fix: r │ ⎿ handover-report: 9f8e7d6 feat: add audit log (3 hours ago)): 0 o │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Handovers
app · log on develop, fix: refresh expired tokens (2 minute… Open 0 Nothing open. 0 done Open questions 0 Repository checkout feat/auth-refresh · 2 uncommitted develop vs mainline 0 ahead, 0 behind worktrees 0
Source 3 files
hooks/register.tsx 304 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { HandoverBranch, HandoverOpenItem, HandoverReport, HandoverWorktree } from '../types'
5import { parseDestination, parseOpenQuestions, parseRows, parseWorktrees } from './parse'
6
7const COMMAND = 'handover-report'
8const PANE = 'handover-report'
9const QUESTIONS_SHOWN = 5
10const WORKTREES_SHOWN = 6
11
12const LOG_PATH = '.handovers/handover_log.md'
13const PARKED_PATH = '.handovers/parked/'
14// Handovers live on trunk; the freshest copy of the log among these wins.
15const LOG_REFS = ['develop', 'main', 'master', 'HEAD']
16
17async function git($: EngineInterface, cwd: string, ...args: string[]): Promise<{ ok: boolean; out: string }> {
18  const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 15_000 })
19  return { ok: r.exitCode === 0, out: r.stdout.trim() }
20}
21
22/** The ref whose last commit to the log is newest, so a stale checkout does not hide new rows. */
23async function freshestLogRef($: EngineInterface, root: string): Promise<{ ref: string; age: string } | null> {
24  let best: { ref: string; at: number; age: string } | null = null
25  for (const ref of LOG_REFS) {
26    const r = await git($, root, 'log', '-1', '--format=%ct %cr', ref, '--', LOG_PATH)
27    if (!r.ok || r.out === '') continue
28    const [at, ...age] = r.out.split(' ')
29    const when = Number(at)
30    if (best === null || when > best.at) best = { ref, at: when, age: age.join(' ') }
31  }
32  return best && { ref: best.ref, age: best.age }
33}
34
35async function firstExisting($: EngineInterface, root: string, names: string[]): Promise<string | null> {
36  for (const name of names) {
37    if ((await git($, root, 'rev-parse', '--verify', '--quiet', `refs/heads/${name}`)).ok) return name
38  }
39  return null
40}
41
42async function branchState(
43  $: EngineInterface,
44  root: string,
45  name: string,
46  mainline: string | null,
47  worktrees: HandoverWorktree[],
48): Promise<HandoverBranch> {
49  const worktree = worktrees.find(w => w.branch === name)?.path ?? null
50  const exists = (await git($, root, 'rev-parse', '--verify', '--quiet', `refs/heads/${name}`)).ok
51  if (!exists) return { name, exists, isMerged: false, ahead: 0, lastCommit: null, worktree }
52
53  const isMerged = mainline !== null && (await git($, root, 'merge-base', '--is-ancestor', name, mainline)).ok
54  const ahead = mainline === null ? 0 : Number((await git($, root, 'rev-list', '--count', `${mainline}..${name}`)).out) || 0
55  const lastCommit = (await git($, root, 'log', '-1', '--format=%cr', name)).out || null
56
57  return { name, exists, isMerged, ahead, lastCommit, worktree }
58}
59
60/** Reads the log and asks git about everything it names. Read-only: no fetch, no writes. */
61async function gather($: EngineInterface, now: number): Promise<HandoverReport> {
62  const cwd = await $.session.cwd()
63  const top = await git($, cwd, 'rev-parse', '--show-toplevel')
64  if (!top.ok) return { kind: 'missing', reason: `${cwd} is not inside a git repository.` }
65
66  const root = top.out
67  const source = await freshestLogRef($, root)
68  if (source === null) return { kind: 'missing', reason: `No ${LOG_PATH} committed in ${root}.` }
69
70  const log = (await git($, root, 'show', `${source.ref}:${LOG_PATH}`)).out
71  const rows = parseRows(log)
72  const mainline = await firstExisting($, root, ['main', 'master'])
73  const worktrees = parseWorktrees((await git($, root, 'worktree', 'list', '--porcelain')).out)
74
75  const open = await Promise.all(
76    rows
77      .filter(r => r.state !== 'done')
78      .map(async row => ({
79        row,
80        branches: await Promise.all(row.branches.map(b => branchState($, root, b, mainline, worktrees))),
81      })),
82  )
83
84  // A worktree whose branch is already in the mainline holds nothing left to ship.
85  const staleWorktrees: HandoverWorktree[] = []
86  for (const w of worktrees) {
87    if (w.path === root || w.branch === mainline || mainline === null) continue
88    if ((await git($, root, 'merge-base', '--is-ancestor', w.branch, mainline)).ok) staleWorktrees.push(w)
89  }
90
91  const done = rows.filter(r => r.state === 'done')
92  const branch = (await git($, root, 'rev-parse', '--abbrev-ref', 'HEAD')).out
93  const status = (await git($, root, 'status', '--porcelain')).out
94  const trunk = source.ref === 'HEAD' ? branch : source.ref
95
96  let trunkDrift: { name: string; ahead: number; behind: number } | null = null
97  if (mainline !== null && trunk !== mainline) {
98    const counts = (await git($, root, 'rev-list', '--left-right', '--count', `${mainline}...${trunk}`)).out.split(/\s+/)
99    trunkDrift = { name: trunk, behind: Number(counts[0]) || 0, ahead: Number(counts[1]) || 0 }
100  }
101
102  const parked = (await git($, root, 'ls-tree', '--name-only', source.ref, PARKED_PATH)).out
103    .split('\n')
104    .filter(p => p !== '')
105    .map(p => p.slice(PARKED_PATH.length))
106
107  return {
108    kind: 'report',
109    repo: root.split('/').at(-1) ?? root,
110    source: source.ref,
111    sourceAge: source.age,
112    destination: parseDestination(log),
113    open,
114    doneCount: done.length,
115    lastDone: done.reduce<(typeof done)[number] | null>(
116      (latest, r) => (latest === null || r.id > latest.id ? r : latest),
117      null,
118    ),
119    questions: parseOpenQuestions(log),
120    checkout: { branch, dirty: status === '' ? 0 : status.split('\n').length },
121    trunk,
122    trunkDrift,
123    worktrees: worktrees.filter(w => w.path !== root).length,
124    staleWorktrees,
125    parked,
126    generatedAt: now,
127  }
128}
129
130const report = atom({ plugin: 'handover-report', key: 'report' } as const, null as HandoverReport | null)
131const isLoading = atom({ plugin: 'handover-report', key: 'isLoading' } as const, false)
132
133async function refresh($: EngineInterface): Promise<HandoverReport> {
134  await update($, isLoading, () => true)
135  try {
136    const next = await gather($, await $.clock.now())
137    await update($, report, () => next)
138    return next
139  } finally {
140    await update($, isLoading, () => false)
141  }
142}
143
144/** The transcript line: the model reads it too, so it carries the open rows by number. */
145export function summarize(r: HandoverReport): string {
146  if (r.kind === 'missing') return `Handover report: ${r.reason}`
147  const open = r.open.map(o => `${o.row.id} ${o.row.slug} (${o.row.status})`).join('; ')
148  return [
149    `Handover report for ${r.repo} (log on ${r.source}, ${r.sourceAge}):`,
150    `${r.open.length} open${open ? ` — ${open}` : ''};`,
151    `${r.doneCount} done; ${r.questions.length} open questions;`,
152    `${r.staleWorktrees.length}/${r.worktrees} worktrees already merged.`,
153  ].join(' ')
154}
155
156function branchLine(b: HandoverBranch): string {
157  if (!b.exists) return 'no local branch'
158  const parts = [b.isMerged ? 'merged into mainline' : `${b.ahead} ahead of mainline`]
159  if (b.lastCommit) parts.push(`last commit ${b.lastCommit}`)
160  if (b.worktree) parts.push('has worktree')
161  return parts.join(' · ')
162}
163
164function relative(path: string, root: string): string {
165  const at = path.indexOf(`/${root}/`)
166  return at === -1 ? path : path.slice(at + root.length + 2)
167}
168
169export const register: Register = on => {
170  on('session.start', async ($, e, next) => {
171    await $.command.register({
172      name: COMMAND,
173      description: 'Show open handovers, their branches and the state of the handover log',
174    })
175    return next(e)
176  })
177
178  on('command.run', { command: COMMAND }, async $ => {
179    void $.ui.open({ id: PANE, title: 'Handovers', focus: true, closeOnEscape: true })
180    const r = await refresh($)
181    return { text: summarize(r) }
182  })
183
184  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185    const { Box, Button, Text } = $.ui.resolve(e)
186    const r = await read($, report)
187    const loading = await read($, isLoading)
188
189    if (r === null) return <Text dimColor>Reading the handover log…</Text>
190    if (r.kind === 'missing') return <Text color="yellow">{r.reason}</Text>
191
192    const heading = (label: string, count?: number) => (
193      <Box marginTop={1}>
194        <Text bold>{label}</Text>
195        {count !== undefined && <Text dimColor> {count}</Text>}
196      </Box>
197    )
198
199    const openItem = (item: HandoverOpenItem) => (
200      <Box flexDirection="column">
201        <Text wrap="truncate">
202          <Text color={item.row.state === 'in-progress' ? 'yellow' : 'cyan'} bold>
203            {item.row.id}
204          </Text>
205          <Text> {item.row.slug} </Text>
206          <Text dimColor>{item.row.status}</Text>
207        </Text>
208        <Text dimColor wrap="truncate">
209          {'    '}created {item.row.created}
210          {item.row.notes > 0 &&
211            ` · ${item.row.notes} note${item.row.notes === 1 ? '' : 's'}, last ${item.row.lastNoteDate ?? '?'}`}
212        </Text>
213        {item.branches.map(b => (
214          <Text wrap="truncate">
215            {'    '}
216            <Text color={b.isMerged ? 'green' : b.exists ? 'white' : 'red'}>{b.name}</Text>
217            <Text dimColor> {branchLine(b)}</Text>
218          </Text>
219        ))}
220      </Box>
221    )
222
223    const drift = r.trunkDrift
224    return (
225      <Box flexDirection="column">
226        <Box justifyContent="space-between">
227          <Text wrap="truncate">
228            <Text bold>{r.repo}</Text>
229            <Text dimColor>
230              {' '}
231              · log on {r.source}, {r.sourceAge}
232            </Text>
233          </Text>
234          <Button key="refresh" label={loading ? 'Reading…' : 'Refresh'} hotkey="r" onPress={() => refresh($)} />
235        </Box>
236        {r.destination && (
237          <Text wrap="wrap">
238            <Text dimColor>Destination </Text>
239            {r.destination}
240          </Text>
241        )}
242
243        {heading('Open', r.open.length)}
244        {r.open.length === 0 && <Text color="green">Nothing open.</Text>}
245        {r.open.map(openItem)}
246        <Text dimColor wrap="truncate">
247          {r.doneCount} done
248          {r.lastDone && ` · latest ${r.lastDone.id} ${r.lastDone.slug} (${r.lastDone.completed || r.lastDone.created})`}
249        </Text>
250
251        {heading('Open questions', r.questions.length)}
252        {r.questions.slice(0, QUESTIONS_SHOWN).map(q => (
253          <Text wrap="truncate">
254            <Text dimColor>{q.raised ?? '          '} </Text>
255            {q.title}
256          </Text>
257        ))}
258        {r.questions.length > QUESTIONS_SHOWN && (
259          <Text dimColor>…and {r.questions.length - QUESTIONS_SHOWN} more in "Not yet specified"</Text>
260        )}
261
262        {heading('Repository')}
263        <Text wrap="truncate">
264          <Text dimColor>checkout </Text>
265          {r.checkout.branch}
266          <Text color={r.checkout.dirty > 0 ? 'yellow' : 'green'}>
267            {r.checkout.dirty > 0 ? ` · ${r.checkout.dirty} uncommitted` : ' · clean'}
268          </Text>
269        </Text>
270        {drift && (
271          <Text wrap="truncate">
272            <Text dimColor>{drift.name} vs mainline </Text>
273            {drift.ahead} ahead, {drift.behind} behind
274          </Text>
275        )}
276        <Text wrap="truncate">
277          <Text dimColor>worktrees </Text>
278          {r.worktrees}
279          {r.staleWorktrees.length > 0 && (
280            <Text color="yellow"> · {r.staleWorktrees.length} already merged (cleanup candidates)</Text>
281          )}
282        </Text>
283        {r.staleWorktrees.slice(0, WORKTREES_SHOWN).map(w => (
284          <Text dimColor wrap="truncate">
285            {'    '}
286            {relative(w.path, r.repo)}
287          </Text>
288        ))}
289        {r.staleWorktrees.length > WORKTREES_SHOWN && (
290          <Text dimColor>
291            {'    '}…and {r.staleWorktrees.length - WORKTREES_SHOWN} more
292          </Text>
293        )}
294        {r.parked.length > 0 && (
295          <Text wrap="truncate">
296            <Text dimColor>parked </Text>
297            {r.parked.join(', ')}
298          </Text>
299        )}
300      </Box>
301    )
302  })
303}
304
hooks/parse.ts 132 lines
1import type { HandoverQuestion, HandoverRow, HandoverState, HandoverWorktree } from '../types'
2
3const ROW_ID = /^\d{3}[a-z]?$/
4const DATE = /(\d{4}-\d{2}-\d{2})/
5
6/** Splits a markdown table line into trimmed cells, leading and trailing pipes dropped. */
7function cells(line: string): string[] {
8  return line.split('|').slice(1, -1).map(c => c.trim())
9}
10
11export function stateOf(status: string): HandoverState {
12  if (/🔄|in[- ]progress/i.test(status)) return 'in-progress'
13  // Closed without shipping still is not open work.
14  if (/✅|done|superseded|abandoned|cancel|dropped/i.test(status)) return 'done'
15  return 'open'
16}
17
18/** `feature/a → feature/b`, `a, b`, backticks: every branch named in the cell. */
19export function splitBranches(cell: string): string[] {
20  return cell
21    .replace(/`/g, '')
22    .split(/→|,|\s+and\s+/)
23    .map(b => b.trim())
24    .filter(b => b !== '' && b !== '—' && b !== '-')
25}
26
27/**
28 * Reads the tracker table (`# | Slug | Goal | Status | Branch | Created | Completed`).
29 * The Goal cell may hold pipes, so the last four cells are read from the right.
30 * `↳` rows are notes on the row with the same number, and may sit before it.
31 */
32export function parseRows(log: string): HandoverRow[] {
33  const rows: HandoverRow[] = []
34  const notes = new Map<string, string[]>()
35
36  for (const line of log.split('\n')) {
37    if (!line.startsWith('|')) continue
38    const c = cells(line)
39    const id = c[0] ?? ''
40    if (!ROW_ID.test(id) || c.length < 7) continue
41    const slugCell = c[1] ?? ''
42
43    if (slugCell.startsWith('↳')) {
44      notes.set(id, [...(notes.get(id) ?? []), slugCell])
45      continue
46    }
47
48    const link = /\[([^\]]+)\]\(([^)]+)\)/.exec(slugCell)
49    const status = c[c.length - 4] ?? ''
50    rows.push({
51      id,
52      slug: link?.[1] ?? slugCell,
53      file: link?.[2] ?? null,
54      status,
55      state: stateOf(status),
56      branches: splitBranches(c[c.length - 3] ?? ''),
57      created: c[c.length - 2] ?? '',
58      completed: c[c.length - 1] ?? '',
59      notes: 0,
60      lastNoteDate: null,
61    })
62  }
63
64  return rows.map(row => {
65    const own = notes.get(row.id) ?? []
66    const dates = own.map(n => DATE.exec(n)?.[1]).filter((d): d is string => d !== undefined)
67    return { ...row, notes: own.length, lastNoteDate: dates.sort().at(-1) ?? null }
68  })
69}
70
71/** The body of a `## <heading>` section, up to the next `## `. */
72function section(log: string, heading: string): string | null {
73  const lines = log.split('\n')
74  const start = lines.findIndex(l => l.trim() === `## ${heading}`)
75  if (start === -1) return null
76  const rest = lines.slice(start + 1)
77  const end = rest.findIndex(l => l.startsWith('## '))
78  return (end === -1 ? rest : rest.slice(0, end)).join('\n')
79}
80
81function plain(text: string): string {
82  return text.replace(/\*\*|`|~~/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/\s+/g, ' ').trim()
83}
84
85/** The Destination's first sentence: what "done" looks like for the active effort. */
86export function parseDestination(log: string): string | null {
87  const body = section(log, 'Destination')
88  if (body === null) return null
89  const paragraph = body.split(/\n\s*\n/).map(p => p.trim()).find(p => p !== '')
90  if (!paragraph) return null
91  const text = plain(paragraph)
92  const sentence = /^.+?[.!?](?=\s|$)/.exec(text)?.[0] ?? text
93
94  return sentence
95}
96
97/**
98 * `## Not yet specified`: one bullet per question, its title in bold.
99 * A struck-through title (`- ~~**...**~~`) is answered and left out.
100 * Newest first by the `Raised YYYY-MM-DD` date in its text; undated last.
101 */
102export function parseOpenQuestions(log: string): HandoverQuestion[] {
103  const body = section(log, 'Not yet specified')
104  if (body === null) return []
105
106  const bullets: string[] = []
107  for (const line of body.split('\n')) {
108    if (line.startsWith('- ')) bullets.push(line)
109    else if (bullets.length > 0 && line.startsWith(' ')) bullets[bullets.length - 1] += ` ${line.trim()}`
110  }
111
112  return bullets
113    .filter(b => b.startsWith('- **'))
114    .map(b => ({
115      title: plain(/^- \*\*(.+?)\*\*/.exec(b)?.[1] ?? b.slice(2)),
116      raised: /Raised (\d{4}-\d{2}-\d{2})/.exec(b)?.[1] ?? null,
117    }))
118    .sort((a, b) => (b.raised ?? '').localeCompare(a.raised ?? ''))
119}
120
121/** `git worktree list --porcelain`: each worktree with the branch it has checked out. */
122export function parseWorktrees(porcelain: string): HandoverWorktree[] {
123  return porcelain
124    .split(/\n\s*\n/)
125    .map(block => {
126      const path = /^worktree (.+)$/m.exec(block)?.[1]
127      const branch = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1]
128      return path && branch ? { path, branch } : null
129    })
130    .filter((w): w is HandoverWorktree => w !== null)
131}
132
types/index.d.ts 60 lines
1/** Where a handover sits by its Status column: ✅ done, 🔄 in progress, anything else open. */
2export type HandoverState = 'done' | 'in-progress' | 'open'
3
4/** One row of the log's tracker table, with its `↳` note rows folded in. */
5export type HandoverRow = {
6  id: string
7  slug: string
8  file: string | null
9  status: string
10  state: HandoverState
11  branches: string[]
12  created: string
13  completed: string
14  notes: number
15  lastNoteDate: string | null
16}
17
18/** A branch a handover names, as git sees it now. */
19export type HandoverBranch = {
20  name: string
21  exists: boolean
22  isMerged: boolean
23  ahead: number
24  lastCommit: string | null
25  worktree: string | null
26}
27
28export type HandoverOpenItem = { row: HandoverRow; branches: HandoverBranch[] }
29
30export type HandoverWorktree = { path: string; branch: string }
31
32export type HandoverQuestion = { title: string; raised: string | null }
33
34export type HandoverReport =
35  | { kind: 'missing'; reason: string }
36  | {
37      kind: 'report'
38      repo: string
39      source: string
40      sourceAge: string
41      destination: string | null
42      open: HandoverOpenItem[]
43      doneCount: number
44      lastDone: HandoverRow | null
45      questions: HandoverQuestion[]
46      checkout: { branch: string; dirty: number }
47      trunk: string
48      trunkDrift: { name: string; ahead: number; behind: number } | null
49      worktrees: number
50      staleWorktrees: HandoverWorktree[]
51      parked: string[]
52      generatedAt: number
53    }
54
55declare module 'claude-code' {
56  interface PluginState {
57    'handover-report': { report: HandoverReport | null; isLoading: boolean }
58  }
59}
60