SLOPSHOPPER

pit-wall

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

newpaneguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-06rhealaloo45/pit-lane-claude-mods/pit-wall
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pit-wall
│ ┃ 🏁 Pit Wall ✕ › fix the failing auth test and add an audit log call │ ┃ 🏁 PIT WALL │ ┃ no plan loaded ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ░░░░░░░░░░░░░░░░░░░░ 0/0 phases ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Lights out — no phases yet. ⏺ Bash(bun test) │ ┃ [ Import latest plan ] ⎿ 3 pass, 1 fail │ ┃ │ ┃ PUSHES ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No pushes logged yet. Any `git push` run │ ┃ here shows up. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ Refresh CI ] [ Re-import plan ] › /pit │ ⎿ pit-wall: Pit wall opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🏁 Pit Wall
🏁 PIT WALL no plan loaded ░░░░░░░░░░░░░░░░░░░░ 0/0 phases Lights out — no phases yet. [ Import latest plan ] PUSHES No pushes logged yet. Any `git push` run here shows up. [ Refresh CI ] [ Re-import plan ]
README

pit-lane-claude-mods

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.

Use on any device

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.

Source 2 files
hooks/register.tsx 263 lines
1import { 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}
263
types/index.d.ts 17 lines
1export 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