Live progress bars above the prompt for Gru, Minion and Agnes runs, read from ~/.cache/stark-progress/

A Claude Code mod that draws live progress bars above the prompt for Gru, Minion and Agnes runs: one bar per ticket, filled by stage, and an n/m closed total in Gru's tab. It polls the state files below once a second.
Install it on its own; the seven skill plugins do not need it:
/plugin install stark-progress@bifrost
The skills write these files; the mod only reads them. They are runtime-neutral: a Codex worker writes them too, and only a Claude Code session draws them. Every file is whole JSON, written atomically: a writer writes <name>.json.tmp beside it and renames it over <name>.json, so a poll never reads a half-written file (the worker spine's command does this for a ticket's file). A field of the wrong type reads as absent: a stage that is not a string draws as ticket.
~/.cache/stark-progress/<STARK-n>.json, one per ticket:
{ "id": "STARK-n", "title": "<ticket title>", "stage": "pr", "pr": "<PR url or null>", "updated": "<ISO 8601>" }
stage is one of ticket, pr, review, merged, closed (the bar fills in that order) or blocked (drawn empty and red).
~/.cache/stark-progress/run-<id>.json, one per Gru run, <id> being Gru's launch id (the epic's, or GRU-n):
{ "epic": "STARK-n", "session": "<Gru's session id>", "tickets": ["STARK-a", "STARK-b"] }
session is Gru's own session id ($CLAUDE_CODE_SESSION_ID, or $CODEX_THREAD_ID on Codex). A ticket with no file yet draws as ticket.
session shows that run: Gru's tab, even though it stands in a worktree named for its launch id (the epic's own STARK-n, or GRU-n).cd does not move it) is a folder named STARK-n, a worker's worktree, shows that ticket's bar, once its file exists.Each poll lists the directory and reads the run files, then only the ticket files the tab draws, so old ticket files cost a listing and nothing more.
claude plugin validate mods/stark-progress
claude plugin test mods/stark-progress
claude --plugin-dir mods/stark-progresshooks/register.tsx 155 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Row, RunFile, Stage, TicketFile, View } from '../types'
5
6// The state contract is in ../README.md.
7const STAGES: Stage[] = ['ticket', 'pr', 'review', 'merged', 'closed']
8const WIDTH = 20
9const POLL_MS = 1000
10const TICKET_ID = /^STARK-\d+$/
11
12const view = atom({ plugin: 'stark-progress', key: 'view' } as const, null)
13
14/** A bar filled by stage; `blocked` and anything unknown draw empty. */
15export function bar(stage: string): string {
16 const at = STAGES.indexOf(stage as Stage)
17 const filled = at < 0 ? 0 : Math.round(((at + 1) / STAGES.length) * WIDTH)
18
19 return '█'.repeat(filled) + '░'.repeat(WIDTH - filled)
20}
21
22function parse<T>(text: string | undefined): Partial<T> | null {
23 if (!text) return null
24 try {
25 const value: unknown = JSON.parse(text)
26
27 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Partial<T>) : null
28 } catch {
29 return null
30 }
31}
32
33/** A ticket's row; a field of the wrong type reads as absent, so drawing never throws. */
34function row(id: string, text: string | undefined): Row {
35 const t = parse<TicketFile>(text)
36
37 return {
38 id,
39 title: typeof t?.title === 'string' ? t.title : '',
40 stage: typeof t?.stage === 'string' ? t.stage : 'ticket',
41 }
42}
43
44const isRunName = (name: string) => name.startsWith('run-') && name.endsWith('.json')
45
46/** The first run file, by name, whose `session` is this session's: Gru's run. */
47export function ownRun(session: string, files: Record<string, string>): RunFile | null {
48 for (const name of Object.keys(files).filter(isRunName).sort()) {
49 const run = parse<RunFile>(files[name])
50 if (run?.session !== session || !Array.isArray(run.tickets)) continue
51 const tickets = run.tickets.filter((id): id is string => typeof id === 'string')
52
53 return { epic: typeof run.epic === 'string' ? run.epic : '', session, tickets: [...new Set(tickets)] }
54 }
55
56 return null
57}
58
59/** The ticket a worker's worktree is named for, from the session's project root. */
60export function ticketOf(root: string): string | null {
61 const base = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
62
63 return TICKET_ID.test(base) ? base : null
64}
65
66/**
67 * What this tab shows, from the session's project root and id and the contract
68 * directory's files by name. A run file whose `session` is this session's wins
69 * (Gru's tab, which stands in a worktree named for its launch id, the epic's
70 * own `STARK-n` included); otherwise a worker's worktree is named for its
71 * ticket, so its tab shows that ticket's file.
72 */
73export function buildView(root: string, session: string, files: Record<string, string>): View | null {
74 const run = ownRun(session, files)
75 if (run !== null) {
76 const rows = run.tickets.map(id => row(id, files[`${id}.json`]))
77 const closed = rows.filter(r => r.stage === 'closed').length
78
79 return { heading: `Gru · ${run.epic} · ${closed}/${rows.length} closed`, rows }
80 }
81
82 const id = ticketOf(root)
83 const text = id === null ? undefined : files[`${id}.json`]
84
85 return id === null || text === undefined ? null : { heading: null, rows: [row(id, text)] }
86}
87
88export const register: Register = on => {
89 let last = ''
90 let isPolling = false
91
92 on('session.start', async ($, e, next) => {
93 const home = await $.env.get('HOME')
94 if (!home) return next(e)
95 const dir = `${home}/.cache/stark-progress`
96
97 $.clock.every(POLL_MS, async () => {
98 // A slow tick must not overlap the next one and write an older view last.
99 if (isPolling) return
100 isPolling = true
101 try {
102 const listed = await $.fs.list(dir).catch(() => [])
103 const names = new Set(listed.filter(f => f.kind === 'file' && f.name.endsWith('.json')).map(f => f.name))
104 const files: Record<string, string> = {}
105 const load = async (name: string) => {
106 if (!names.has(name)) return
107 const text = await $.fs.read(`${dir}/${name}`).catch(() => undefined)
108 if (typeof text === 'string') files[name] = text
109 }
110
111 // The run files first; then only the ticket files this tab draws, so a
112 // directory of old tickets costs a listing, not a read of each.
113 const [root, session] = await Promise.all([$.session.root(), $.session.id()])
114 await Promise.all([...names].filter(isRunName).map(load))
115 const run = ownRun(session, files)
116 const ticket = ticketOf(root)
117 const wanted = run !== null ? run.tickets : ticket === null ? [] : [ticket]
118 await Promise.all(wanted.map(id => load(`${id}.json`)))
119
120 const fresh = buildView(root, session, files)
121 const key = JSON.stringify(fresh)
122 if (key === last) return
123 last = key
124 await update($, view, () => fresh)
125 } finally {
126 isPolling = false
127 }
128 })
129
130 return next(e)
131 })
132
133 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
134 const current = await read($, view)
135 if (e.props.hasSurvey || current === null || current.rows.length === 0) {
136 return next(e)
137 }
138
139 const { Box, Text } = $.ui.resolve(e)
140 const color = (stage: Stage) => (stage === 'closed' ? 'green' : stage === 'blocked' ? 'red' : 'cyan')
141
142 return (
143 <Box flexDirection="column">
144 {current.heading === null ? null : <Text bold>{current.heading}</Text>}
145 {current.rows.map(r => (
146 <Text key={r.id}>
147 <Text color={color(r.stage)}>{bar(r.stage)}</Text> {r.id.padEnd(11)} {r.stage.padEnd(7)}{' '}
148 <Text dimColor>{r.title}</Text>
149 </Text>
150 ))}
151 </Box>
152 )
153 })
154}
155types/index.d.ts 29 lines1export type Stage = 'ticket' | 'pr' | 'review' | 'merged' | 'closed' | 'blocked'
2
3/** `~/.cache/stark-progress/<STARK-n>.json`, one per ticket. */
4export type TicketFile = {
5 id: string
6 title: string
7 stage: Stage
8 pr: string | null
9 updated: string
10}
11
12/** `~/.cache/stark-progress/run-<id>.json`, one per Gru run. */
13export type RunFile = {
14 epic: string
15 session: string
16 tickets: string[]
17}
18
19export type Row = { id: string; title: string; stage: Stage }
20
21/** What the band draws: a Gru run's rows, or a worker's one row. */
22export type View = { heading: string | null; rows: Row[] }
23
24declare module 'claude-code' {
25 interface PluginState {
26 'stark-progress': { view: View | null }
27 }
28}
29