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

hooks/register.tsx 304 lines1import { 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}
304hooks/parse.ts 132 lines1import 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}
132types/index.d.ts 60 lines1/** 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