F1-style pit wall pane: track build-plan phases and GitHub pushes

My collection of Claude Code mods: live panes, bands and hooks written as function-hook plugins.
Two function-hook mods for Claude Code (needs a recent build; 2.1.286+ here).
usage-buddy: live usage band (context, 5h, 7d) with the Claude mascot and model-switch hints per prompt.pit-wall: F1-style /pit pane tracking plan phases and GitHub pushes.git clone <this repo> ~/claude-mods
claude --plugin-dir ~/claude-mods/usage-buddy --plugin-dir ~/claude-mods/pit-wall
To load them in every session, set CLAUDE_CODE_PLUGIN_DIRS (colon-separated absolute paths) in your shell profile or in the env block of ~/.claude/settings.json.
pit-wall stores its board per machine and per project folder, and uses git, plus gh for CI status if installed.
hooks/register.tsx 263 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Board, Phase, Push, Status } from '../types'
5
6const PANE = 'pit-wall'
7const EMPTY: Board = { repo: '', planPath: null, phases: [], pushes: [] }
8const boardAtom = atom({ plugin: 'pit-wall', key: 'board' } as const, EMPTY)
9
10const SYMBOL: Record<Status, string> = { todo: '○', doing: '◐', done: '●' }
11const NEXT: Record<Status, Status> = { todo: 'doing', doing: 'done', done: 'todo' }
12
13const storeKey = (cwd: string) => `pit-wall:${cwd}`
14
15async function persist($: any, board: Board) {
16 await update($, boardAtom, () => board)
17 await $.store.set(storeKey(await $.session.cwd()), board)
18}
19
20async function change($: any, fn: (b: Board) => Board) {
21 await persist($, fn(await read($, boardAtom)))
22}
23
24const sh = ($: any, script: string) => $.process.run(['sh', '-c', script])
25
26function ago(ms: number): string {
27 const m = Math.max(0, Math.round(ms / 60000))
28 if (m < 1) return 'now'
29 if (m < 60) return `${m}m`
30 if (m < 1440) return `${Math.round(m / 60)}h`
31 return `${Math.round(m / 1440)}d`
32}
33
34// Phases come from "Phase N: title" headings (any level); fallback to ## headings.
35// Bullets and checkboxes under a phase become its tasks.
36function parsePlan(md: string, old: Phase[]): Phase[] {
37 const lines = md.split('\n')
38 const phaseRe = /^#{1,4}\s*(?:phase|stage|step)\s*(\d+)\s*[:.\-—–)]*\s*(.*)$/i
39 let found = lines.some(l => phaseRe.test(l))
40 const phases: Phase[] = []
41 let cur: Phase | null = null
42 for (const line of lines) {
43 let title: string | null = null
44 const m = line.match(phaseRe)
45 if (m) title = (m[2] || `Phase ${m[1]}`).trim()
46 else if (!found) {
47 const h = line.match(/^##\s+(.*)$/)
48 if (h) title = h[1].trim()
49 }
50 if (title !== null) {
51 cur = { id: phases.length + 1, title: title.replace(/\*\*/g, ''), status: 'todo', tasks: [] }
52 phases.push(cur)
53 continue
54 }
55 const t = cur && line.match(/^\s*(?:[-*]|\d+\.)\s+(?:\[([ xX])\]\s+)?(.+)$/)
56 if (cur && t && cur.tasks.length < 12) {
57 cur.tasks.push({ text: t[2].replace(/\*\*/g, '').slice(0, 90), isDone: t[1] === 'x' || t[1] === 'X' })
58 }
59 }
60 for (const p of phases) {
61 const prev = old.find(o => o.title === p.title)
62 if (prev) {
63 p.status = prev.status
64 p.tasks = p.tasks.map(t => ({ ...t, isDone: t.isDone || !!prev.tasks.find(o => o.text === t.text && o.isDone) }))
65 }
66 }
67 return phases
68}
69
70async function importPlan($: any, path?: string): Promise<string> {
71 let file = path?.trim()
72 if (!file) {
73 const r = await sh($, 'ls -t PLAN.md plan.md docs/PLAN.md docs/plan.md 2>/dev/null | head -1; ls -t "$HOME"/.claude/plans/*.md 2>/dev/null | head -1')
74 file = r.stdout.split('\n').filter(Boolean)[0]
75 }
76 if (!file) return 'No plan found. Use /pit plan <path-to-plan.md>.'
77 const r = await $.process.run(['cat', file])
78 if (r.exitCode !== 0) return `Could not read ${file}.`
79 const board = await read($, boardAtom)
80 const phases = parsePlan(r.stdout, board.phases)
81 if (phases.length === 0) return `No phases found in ${file}. Use headings like "## Phase 1: Setup".`
82 await persist($, { ...board, planPath: file, phases })
83 return `Loaded ${phases.length} phases from ${file}.`
84}
85
86async function refreshCi($: any) {
87 const r = await sh($, 'gh run list --limit 20 --json headSha,status,conclusion 2>/dev/null')
88 if (r.exitCode !== 0) return false
89 let runs: { headSha: string; status: string; conclusion: string }[] = []
90 try { runs = JSON.parse(r.stdout) } catch { return false }
91 await change($, b => ({
92 ...b,
93 pushes: b.pushes.map(p => {
94 const run = runs.find(x => x.headSha.startsWith(p.sha) || p.sha.startsWith(x.headSha.slice(0, 7)))
95 if (!run) return p
96 return { ...p, ci: run.status !== 'completed' ? 'running' : run.conclusion === 'success' ? 'pass' : 'fail' }
97 }),
98 }))
99 return true
100}
101
102async function recordPush($: any) {
103 const info = await sh($, 'git log -1 --format=%h%x1f%s; git rev-parse --abbrev-ref HEAD; git remote get-url origin 2>/dev/null')
104 const [head = '', branch = '', url = ''] = info.stdout.split('\n')
105 const [sha, subject = ''] = head.split('\x1f')
106 if (!sha) return
107 const repo = url.replace(/^.*github\.com[:/]/, '').replace(/\.git$/, '').trim()
108 const board = await read($, boardAtom)
109 const prev = board.pushes[0]
110 const range = await sh($, prev ? `git log ${prev.sha}..HEAD --format=%s 2>/dev/null || git log -3 --format=%s` : 'git log -3 --format=%s')
111 const hit = new Set<number>()
112 for (const m of `${subject}\n${range.stdout}`.matchAll(/phase\s*(\d+)/gi)) hit.add(Number(m[1]))
113 const push: Push = { sha, subject, branch, at: await $.clock.now(), phases: [...hit] }
114 await persist($, {
115 ...board,
116 repo: repo || board.repo,
117 pushes: [push, ...board.pushes].slice(0, 30),
118 phases: board.phases.map(p => (hit.has(p.id) ? { ...p, status: 'done' as Status } : p)),
119 })
120 $.ui.toast(`🏁 Push logged: ${branch} ${sha}${hit.size ? ` · phase ${[...hit].join(', ')} done` : ''}`)
121 void refreshCi($)
122}
123
124function setStatus(b: Board, id: number, status: Status): Board {
125 return {
126 ...b,
127 phases: b.phases.map(p =>
128 p.id === id ? { ...p, status } : status === 'doing' && p.status === 'doing' ? { ...p, status: 'todo' as Status } : p,
129 ),
130 }
131}
132
133export const register: Register = on => {
134 on('session.start', async ($, e, next) => {
135 const saved = (await $.store.get(storeKey(await $.session.cwd()))) as Board | undefined
136 if (saved) await update($, boardAtom, () => saved)
137 await $.command.register({ name: 'pit', description: 'Open the pit wall: plan phases and GitHub pushes (/pit plan|start|done|add|ci|reset)' })
138 return next(e)
139 })
140
141 on('command.run', { command: 'pit' }, async ($, e) => {
142 const [cmd = '', ...rest] = e.args.trim().split(/\s+/)
143 const arg = rest.join(' ')
144 const n = Number(rest[0])
145 let text = 'Pit wall opened.'
146 if (cmd === 'plan') text = await importPlan($, arg)
147 else if (cmd === 'start' || cmd === 'done') {
148 await change($, b => setStatus(b, n, cmd === 'start' ? 'doing' : 'done'))
149 text = `Phase ${n} ${cmd === 'start' ? 'on track' : 'done'}.`
150 } else if (cmd === 'add' && arg) {
151 await change($, b => ({ ...b, phases: [...b.phases, { id: b.phases.length + 1, title: arg, status: 'todo', tasks: [] }] }))
152 text = `Added phase: ${arg}`
153 } else if (cmd === 'ci') text = (await refreshCi($)) ? 'CI status refreshed.' : 'gh not available or not logged in.'
154 else if (cmd === 'reset') {
155 await persist($, EMPTY)
156 text = 'Pit wall cleared.'
157 }
158 await $.ui.open({ id: PANE, title: '🏁 Pit Wall' })
159 return { text }
160 })
161
162 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
163 const ran = await next(e)
164 if (/\bgit\s+push\b/.test(e.command) && ran.deny === undefined && ran.isError !== true) {
165 await recordPush($)
166 }
167 return ran
168 })
169
170 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
171 const { Box, Text, Button } = $.ui.resolve(e)
172 const board = await read($, boardAtom)
173 const { phases, pushes } = board
174 const done = phases.filter(p => p.status === 'done').length
175 const N = 20
176 const lit = phases.length ? Math.round((done / phases.length) * N) : 0
177 const now = await $.clock.now()
178 const current = phases.find(p => p.status === 'doing') ?? phases.find(p => p.status === 'todo')
179
180 return (
181 <Box flexDirection="column" gap={1}>
182 <Box flexDirection="column">
183 <Text bold color="red">🏁 PIT WALL <Text dimColor>{board.repo}</Text></Text>
184 <Text dimColor>{board.planPath ? `plan: ${board.planPath}` : 'no plan loaded'}</Text>
185 </Box>
186
187 <Box flexDirection="column">
188 <Text>
189 <Text color="green">{'█'.repeat(Math.min(lit, 8))}</Text>
190 <Text color="red">{'█'.repeat(Math.max(0, Math.min(lit, 13) - 8))}</Text>
191 <Text color="magenta">{'█'.repeat(Math.max(0, lit - 13))}</Text>
192 <Text dimColor>{'░'.repeat(N - lit)}</Text>
193 <Text bold> {done}/{phases.length} phases</Text>
194 </Text>
195 </Box>
196
197 {phases.length === 0 ? (
198 <Box flexDirection="column">
199 <Text dimColor>Lights out — no phases yet.</Text>
200 <Button key="import" label="Import latest plan" variant="primary"
201 onPress={async () => { await importPlan($) }} />
202 </Box>
203 ) : (
204 <Box flexDirection="column">
205 <Text bold>STINTS</Text>
206 {phases.map(p => (
207 <Box key={`p${p.id}`} flexDirection="column">
208 <Box flexDirection="row">
209 <Button key={`s${p.id}`} plain label={SYMBOL[p.status]}
210 onPress={() => change($, b => setStatus(b, p.id, NEXT[p.status]))} />
211 <Text dimColor={p.status === 'done'} bold={p === current}>
212 {' '}{p.id}. {p.title}
213 </Text>
214 {p.tasks.length > 0 && (
215 <Text dimColor> {p.tasks.filter(t => t.isDone).length}/{p.tasks.length}</Text>
216 )}
217 {p === current && p.status === 'doing' && <Text color="red" bold> ◀ ON TRACK</Text>}
218 </Box>
219 {p === current && p.tasks.map((t, i) => (
220 <Box key={`t${p.id}-${i}`} flexDirection="row" marginLeft={3}>
221 <Button key={`tk${p.id}-${i}`} plain label={t.isDone ? '☑' : '☐'}
222 onPress={() => change($, b => ({
223 ...b,
224 phases: b.phases.map(q => q.id === p.id
225 ? { ...q, tasks: q.tasks.map((x, j) => (j === i ? { ...x, isDone: !x.isDone } : x)) }
226 : q),
227 }))} />
228 <Text dimColor={t.isDone}> {t.text}</Text>
229 </Box>
230 ))}
231 </Box>
232 ))}
233 </Box>
234 )}
235
236 <Box flexDirection="column">
237 <Text bold>PUSHES</Text>
238 {pushes.length === 0 && <Text dimColor>No pushes logged yet. Any `git push` run here shows up.</Text>}
239 {pushes.slice(0, 8).map(p => (
240 <Text key={`${p.sha}${p.at}`}>
241 <Text dimColor>⬆ {ago(now - p.at).padEnd(4)}</Text>
242 <Text color="cyan">{p.branch} </Text>
243 <Text dimColor>{p.sha} </Text>
244 <Text>{p.subject.slice(0, 48)}</Text>
245 {p.phases && p.phases.length > 0 && <Text color="green"> ✓P{p.phases.join(',')}</Text>}
246 {p.ci && (
247 <Text color={p.ci === 'pass' ? 'green' : p.ci === 'fail' ? 'red' : 'yellow'}>
248 {' '}CI {p.ci === 'pass' ? '✔' : p.ci === 'fail' ? '✘' : '…'}
249 </Text>
250 )}
251 </Text>
252 ))}
253 </Box>
254
255 <Box flexDirection="row" gap={2}>
256 <Button key="ci" label="Refresh CI" onPress={async () => { await refreshCi($) }} />
257 <Button key="replan" label="Re-import plan" onPress={async () => { await importPlan($, board.planPath ?? undefined) }} />
258 </Box>
259 </Box>
260 )
261 })
262}
263types/index.d.ts 17 lines1export type Status = 'todo' | 'doing' | 'done'
2export type Task = { text: string; isDone: boolean }
3export type Phase = { id: number; title: string; status: Status; tasks: Task[] }
4export type Push = { sha: string; subject: string; branch: string; at: number; ci?: string; phases?: number[] }
5export type Board = {
6 repo: string
7 planPath: string | null
8 phases: Phase[]
9 pushes: Push[]
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'pit-wall': { board: Board }
15 }
16}
17