Due followthrough checks for this repo above the prompt (Run / Snooze / Close), and a nudge when the session ships without registering a check

A Claude Code mod that shows followthrough inside the session: the checks due for the repo you are in sit above the prompt, and a session that ships something without registering a check gets a nudge.

followthrough status --json --repo <session root>, refreshed every 5 minutes and after each turn): up to three rows, overdue or "needs you" first, each withstart first (stops on TAKEN/CLOSED), the runbook from show, a read-only check, then resolve with a measured value;followthrough snooze <id> --for 1d;followthrough abandon <id> --reason ....gh pr merge, gcloud run deploy, fly deploy, git push ... main, vercel --prod, wrangler deploy, npm publish, terraform apply or similar in this session with no followthrough add after it, a row offers Register checks (asks the agent to register them with the followthrough skill) or Not needed. Quoted text, dry runs and commands sent to the background do not count./ft hides or shows the band and reports the counts. Headless sessions (claude -p, SDK) run nothing.
The followthrough CLI must be installed first (see the main README); the band calls ~/.local/bin/followthrough, else the followthrough on your PATH.
claude plugin marketplace add bayramannakov/followthrough
claude plugin install followthrough-band@followthrough
Or load it from a clone: claude --plugin-dir <clone>/mod. Needs Claude Code 2.1.286 or newer with mods enabled. If no band ever appears, a Claude Code session older than 2.1.286 that is still running may have switched mods off for everyone (it writes the flag into the shared ~/.claude.json) - restart old sessions.
claude plugin validate mod
claude plugin test mod # 10 tests, mocked CLI - freehooks/register.tsx 242 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Claim, Shipped } from '../types'
5
6const claims = atom({ plugin: 'followthrough-band', key: 'claims' } as const, [])
7const shipped = atom({ plugin: 'followthrough-band', key: 'shipped' } as const, [])
8const isHidden = atom({ plugin: 'followthrough-band', key: 'isHidden' } as const, false)
9const confirmClose = atom({ plugin: 'followthrough-band', key: 'confirmClose' } as const, null)
10
11const REFRESH_MS = 5 * 60 * 1000
12const MIN_GAP_MS = 60 * 1000
13const DAY_MS = 24 * 60 * 60 * 1000
14const MAX_ROWS = 3
15
16/** Commands that ship a change somewhere other people see it. */
17export const SHIP =
18 /\b(gcloud\s+run\s+(deploy|services\s+update(-traffic)?)|gcloud\s+builds\s+submit|fly(ctl)?\s+deploy|gh\s+pr\s+merge|vercel\b[^|;&]*--prod|wrangler\s+deploy|firebase\s+deploy|npm\s+publish|pnpm\s+publish|terraform\s+apply|git\s+push\b[^|;&]*\b(main|master)\b)/
19const DRY = /--dry-run|--help|\s-h\b/
20const REGISTERED = /\bfollowthrough\s+(add|expect)\b/
21
22let lastFetch = 0
23let isFetching = false
24
25/** The command with its quoted strings blanked: `echo "fly deploy"` names a deploy, it does not run one. */
26export const unquoted = (command: string) => command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
27
28export const isShip = (command: string) => {
29 const bare = unquoted(command)
30 return SHIP.test(bare) && !DRY.test(bare)
31}
32
33/** The CLI where `uv tool install` puts it, else whatever `followthrough` the PATH finds. */
34async function bin($: EngineInterface) {
35 const home = await $.env.get('HOME')
36 const local = home === undefined ? undefined : `${home}/.local/bin/followthrough`
37 return local !== undefined && (await $.fs.exists(local)) ? local : 'followthrough'
38}
39
40export function parseStatus(stdout: string, now: number): Claim[] {
41 let rows: unknown
42 try {
43 rows = JSON.parse(stdout)
44 } catch {
45 return []
46 }
47 if (!Array.isArray(rows)) return []
48 const out: Claim[] = []
49 for (const row of rows as unknown[]) {
50 if (typeof row !== 'object' || row === null || Array.isArray(row)) continue
51 const r = row as Record<string, unknown>
52 if (typeof r.id !== 'string' || typeof r.checkpoint !== 'object' || r.checkpoint === null) continue
53 const cp = r.checkpoint as Record<string, unknown>
54 if (typeof cp.due_at !== 'string' || Number.isNaN(Date.parse(cp.due_at))) continue
55 const snoozed = typeof r.snoozed_until === 'string' && r.snoozed_until !== '' ? Date.parse(r.snoozed_until) : 0
56 if (r.status !== 'active' || snoozed > now) continue
57 out.push({
58 id: r.id as string,
59 title: typeof r.title === 'string' ? r.title : (r.id as string),
60 state: String(cp.state ?? ''),
61 dueAt: String(cp.due_at ?? ''),
62 })
63 }
64 return out.sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt))
65}
66
67export const isDue = (c: Claim, now: number) => c.state === 'needs_human' || Date.parse(c.dueAt) <= now
68
69export function dueLabel(c: Claim, now: number) {
70 const ms = now - Date.parse(c.dueAt)
71 if (Number.isNaN(ms)) return 'due'
72 if (ms < 0) return 'needs you'
73 const h = Math.floor(ms / 3600000)
74 return h < 1 ? 'due now' : h < 48 ? `overdue ${h}h` : `overdue ${Math.floor(h / 24)}d`
75}
76
77/** Runs the followthrough CLI; undefined when it is missing, fails to start, or times out. */
78async function ft($: EngineInterface, args: string[]) {
79 try {
80 return await $.process.run([await bin($), ...args], { timeoutMs: 15000 })
81 } catch {
82 return undefined
83 }
84}
85
86/** One refresh at a time; a missing or failing CLI leaves the band as it was. */
87async function refresh($: EngineInterface) {
88 if (isFetching) return
89 isFetching = true
90 try {
91 lastFetch = await $.clock.now()
92 const ran = await ft($, ['status', '--json', '--repo', await $.session.root()])
93 if (ran === undefined || ran.exitCode !== 0) return
94 await update($, claims, () => parseStatus(ran.stdout, lastFetch))
95 } finally {
96 isFetching = false
97 }
98}
99
100async function runCheck($: EngineInterface, c: Claim) {
101 await $.prompt.submit({
102 text: [
103 `[followthrough claim ${c.id}] Run this check now, in this session.`,
104 `1) Run: followthrough start ${c.id} - it prints OK <attempt-id>. If it prints TAKEN or CLOSED, stop and tell me exactly what it printed.`,
105 `2) Read the runbook with followthrough show ${c.id} and do the check. It is read-only: no deploys, sends, publishes or setting changes.`,
106 `3) Record the result, always: followthrough resolve ${c.id} --attempt <attempt-id> --verdict worked|failed|partial|inconclusive --summary "<measured value vs expected, source, window>" (not_settled with --retry-at when the data is not in yet).`,
107 ].join('\n'),
108 })
109}
110
111async function snooze($: EngineInterface, c: Claim) {
112 const ran = await ft($, ['snooze', c.id, '--for', '1d'])
113 $.ui.toast(ran?.exitCode === 0 ? `Snoozed ${c.id} for a day` : `snooze failed: ${(ran?.stderr ?? 'followthrough did not run').trim().slice(0, 120)}`)
114 await refresh($)
115}
116
117/** Close takes two presses: the first arms it, the second abandons the claim. */
118async function close($: EngineInterface, c: Claim) {
119 if ((await read($, confirmClose)) !== c.id) {
120 await update($, confirmClose, () => c.id)
121 return
122 }
123 await update($, confirmClose, () => null)
124 const ran = await ft($, ['abandon', c.id, '--reason', 'closed from the Claude Code band without running it'])
125 $.ui.toast(ran?.exitCode === 0 ? `Closed ${c.id}` : `close failed: ${(ran?.stderr ?? 'followthrough did not run').trim().slice(0, 120)}`)
126 await refresh($)
127}
128
129async function registerShipped($: EngineInterface, list: Shipped[]) {
130 await update($, shipped, () => [])
131 const commands = list.map(s => `- \`${s.command.slice(0, 160)}\``).join('\n')
132 const sent = await $.prompt.submit({
133 text:
134 'This session shipped these without a followthrough check:\n' +
135 commands +
136 '\n\nRegister followthrough checks for them with the followthrough skill: the expected effect written before the data exists, a live check, and when to look. Skip any that need no check and say why.',
137 })
138 if (sent.drop !== undefined) {
139 // The prompt did not enter: keep the nudge so the person can try again.
140 await update($, shipped, current => [...list, ...current].slice(-5))
141 $.ui.toast(`followthrough-band: the prompt was dropped: ${sent.drop}`)
142 }
143}
144
145async function dismissShipped($: EngineInterface) {
146 await update($, shipped, () => [])
147}
148
149async function toggle($: EngineInterface) {
150 await update($, isHidden, hidden => !hidden)
151}
152
153export const register: Register = on => {
154 on('session.start', async ($, e, next) => {
155 await $.command.register({ name: 'ft', description: 'Show or hide the followthrough band, and refresh it' })
156 // Headless runs (claude -p, graders) draw nothing: no timers, no polling.
157 if (e.isInteractive) {
158 // Never hold up the first prompt on the CLI: poll in the background, timer first.
159 $.clock.every(REFRESH_MS, () => void refresh($).catch(() => undefined))
160 void refresh($).catch(() => undefined)
161 }
162 return next(e)
163 })
164
165 on('command.run', { command: 'ft' }, async $ => {
166 await toggle($)
167 await refresh($)
168 const list = await read($, claims)
169 const now = await $.clock.now()
170 const due = list.filter(c => isDue(c, now)).length
171 return { text: `band ${(await read($, isHidden)) ? 'hidden (/ft shows it)' : 'shown'} · ${due} due here · ${list.length} open` }
172 })
173
174 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
175 const ran = await next(e)
176 if (ran.deny !== undefined || ran.isError === true) return ran
177 // A command sent to the background has not finished: it shipped nothing yet.
178 if (ran.result?.backgroundTaskId !== undefined) return ran
179 const bare = unquoted(e.command)
180 if (REGISTERED.test(bare)) {
181 // A check registered: it covers what shipped before it in this session.
182 await update($, shipped, () => [])
183 void refresh($).catch(() => undefined)
184 }
185 if (isShip(e.command) && !REGISTERED.test(bare)) {
186 const at = await $.clock.now()
187 await update($, shipped, list => [...list, { command: e.command.trim(), at }].slice(-5))
188 }
189 return ran
190 })
191
192 on('turn.complete', async ($, e, next) => {
193 if (e.agentId === undefined && (await $.clock.now()) - lastFetch > MIN_GAP_MS) await refresh($)
194 return next(e)
195 })
196
197 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
198 if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
199 const now = await $.clock.now()
200 const list = await read($, claims)
201 const ship = await read($, shipped)
202 const arming = await read($, confirmClose)
203 const due = list.filter(c => isDue(c, now))
204 const soon = list.filter(c => !isDue(c, now) && Date.parse(c.dueAt) - now < DAY_MS).length
205 if (due.length === 0 && ship.length === 0) return next(e)
206
207 const { Box, Button, Text } = $.ui.resolve(e)
208 const width = Math.max(20, e.props.bodyColumns - 44)
209 const below = await next(e)
210 return (
211 <Box flexDirection="column">
212 {due.length > 0 && (
213 <Text dimColor>
214 followthrough · {due.length} due here{soon > 0 ? ` · ${soon} more in 24h` : ''}
215 {due.length > MAX_ROWS ? ` · showing ${MAX_ROWS}` : ''}
216 </Text>
217 )}
218 {due.slice(0, MAX_ROWS).map(c => (
219 <Box key={`row:${c.id}`} flexDirection="row" gap={1}>
220 <Text color={c.state === 'needs_human' ? 'yellow' : undefined} wrap="truncate-end">
221 {`${dueLabel(c, now)} · ${c.title}`.slice(0, width)}
222 </Text>
223 <Button key={`run:${c.id}`} label="Run" onPress={() => runCheck($, c)} />
224 <Button key={`snooze:${c.id}`} label="Snooze 1d" onPress={() => snooze($, c)} />
225 <Button key={`close:${c.id}`} label={arming === c.id ? 'Confirm close' : 'Close'} onPress={() => close($, c)} />
226 </Box>
227 ))}
228 {ship.length > 0 && (
229 <Box key="shipped" flexDirection="row" gap={1}>
230 <Text color="cyan" wrap="truncate-end">
231 {`Shipped without a followthrough: ${ship.map(s => s.command.split(/\s+/).slice(0, 4).join(' ')).join(' · ')}`.slice(0, width)}
232 </Text>
233 <Button key="ship:register" variant="primary" label="Register checks" onPress={() => registerShipped($, ship)} />
234 <Button key="ship:dismiss" label="Not needed" onPress={() => dismissShipped($)} />
235 </Box>
236 )}
237 {below}
238 </Box>
239 )
240 })
241}
242types/index.d.ts 18 lines1/** One open claim as the band draws it, cut from `followthrough status --json`. */
2export type Claim = { id: string; title: string; state: string; dueAt: string }
3
4/** A shipping command this session ran (deploy, merge, publish). */
5export type Shipped = { command: string; at: number }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'followthrough-band': {
10 claims: Claim[]
11 shipped: Shipped[]
12 isHidden: boolean
13 /** The claim whose Close waits for a second press. */
14 confirmClose: string | null
15 }
16 }
17}
18