Know when main is broken before you build on it: a red row above the prompt says how long your repo's default branch has been failing CI and how many pushes…

A Claude Code mod that tells you when main is broken, before you build on it.
+33 pushes). ● main red 37d 0h · +33 pushes hide
ci ● green, ◐ while a run is going.A push is red if any of its workflow runs failed (failure, timed_out, startup_failure, action_required), green when its runs succeeded. Pushes whose runs were all cancelled or skipped don't count either way. A + means the streak goes past the last 100 runs.
/plugin marketplace add davekiss/mods
/plugin install ci-light@davekiss
Then start a new session in a GitHub repo. From a checkout: claude --plugin-dir ./ci-light.
A repo with one chronically failing side workflow (benchmarks, nightly) would read red forever. Set the plugin's workflow option to the ones that decide red or green, by name or file: ci, or ci.yml,test.yml. As a statusline command, use CI_LIGHT_WORKFLOW=ci.
The engine is one dependency-free Node file, and it also works as a statusline:
mkdir -p ~/.claude && cp ci-light.mjs ~/.claude/ci-light.mjs
{ "statusLine": { "type": "command", "command": "node ~/.claude/ci-light.mjs" } }
It prints main ● red 32d 22h · 33 pushes. node ci-light.mjs --json prints the result as JSON, and --fixture runs.json reads { default_branch, workflow_runs } from a file instead of GitHub.
gh when you're signed in (private repos too), otherwise the public GitHub API: 60 requests an hour without GITHUB_TOKEN, two per repo per refresh. A refused call shows ci ? (GitHub 403) for 30 s.CI_LIGHT_TTL in seconds).node --test tests/engine.spec.mjs runs the engine's tests.
hooks/register.tsx 85 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Light } from '../types'
5
6const RED = '#E4312B'
7const REFRESH_MS = 120_000 // ci-light.mjs caches each repo for as long
8
9const light = atom({ plugin: 'ci-light', key: 'light' } as const, null as Light | null)
10const hidden = atom({ plugin: 'ci-light', key: 'hidden' } as const, null as string | null)
11const told = atom({ plugin: 'ci-light', key: 'told' } as const, null as string | null)
12
13// One red streak, so "hide" and "told the model" last until main changes.
14export const streakKey = (l: Light) => `${l.repo}:${l.state}:${l.since?.split(' ')[0] ?? ''}`
15
16// What the model reads with the next prompt while main is red; null when there's nothing to say.
17export function agentNote(l: Light | null): string | null {
18 if (l?.state !== 'red') return null
19 const on = l.pushes ? `, and ${l.pushes}${l.more ? '+' : ''} push${l.pushes === 1 && !l.more ? '' : 'es'} landed on top` : ''
20 const which = l.workflows ? ` (workflows: ${l.workflows})` : ''
21 return `ci-light: ${l.repo}'s default branch ${l.branch} has been failing CI${which} for ${l.since}${l.more ? '+' : ''}${on}. Before building on it or saying a change works, check whether your change touches the failure (gh run list --branch ${l.branch}).`
22}
23
24async function refresh($: EngineInterface, workflow: string) {
25 const argv = ['node', `${$.plugin.root}/ci-light.mjs`, '--json']
26 if (workflow) argv.push('--workflow', workflow)
27 const ran = await $.process.run(argv, { timeoutMs: 15_000 })
28 if (ran.exitCode !== 0) return
29 try {
30 const l = JSON.parse(ran.stdout) as Light | null
31 await update($, light, () => l)
32 } catch {}
33}
34
35export const register: Register = (on, options) => {
36 const workflow = String(options.workflow ?? '').trim()
37
38 on('session.start', async ($, e, next) => {
39 await refresh($, workflow)
40 $.clock.every(REFRESH_MS, () => refresh($, workflow))
41 return next(e)
42 })
43
44 on('turn.complete', async ($, e, next) => {
45 const out = await next(e)
46 await refresh($, workflow)
47 return out
48 })
49
50 // The agent sees it too: once per red streak, the next prompt carries a note.
51 on('prompt.submit', async ($, e, next) => {
52 const l = await read($, light)
53 const note = agentNote(l)
54 if (note === null || l === null || (await read($, told)) === streakKey(l)) return next(e)
55 await update($, told, () => streakKey(l))
56 return next({ ...e, context: [...(e.context ?? []), note] })
57 }).catch(($, e, next) => next(e)) // a failed note never blocks the prompt
58
59 // Red main: one row above the prompt you can't miss.
60 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61 if (e.props.hasSurvey || e.props.isWorking) return next(e)
62 const l = await read($, light)
63 if (l?.state !== 'red' || (await read($, hidden)) === streakKey(l)) return next(e)
64 const { Box, Text, Button } = $.ui.resolve(e)
65 const more = l.more ? '+' : ''
66 const onTop = l.pushes > 0 ? `· +${l.pushes}${more} push${l.pushes === 1 && !more ? '' : 'es'}` : '' // pushes landed on top of the first red one
67 return (
68 <Box gap={1}>
69 <Text color={RED} bold>{`● ${l.branch} red ${l.since}${more}`}</Text>
70 {onTop && <Text>{onTop}</Text>}
71 {l.workflows && <Text dimColor>{l.workflows}</Text>}
72 <Button key="ci-light-hide" plain dimColor label="hide" onPress={() => update($, hidden, () => streakKey(l))} />
73 </Box>
74 )
75 })
76
77 // Otherwise a quiet word under the prompt: green, running, or red once hidden.
78 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
79 const l = await read($, light)
80 if (!l || l.state === 'error') return next(e)
81 const word = l.state === 'red' ? `● red ${l.since}` : l.state === 'green' ? `● green${l.running ? ' ◐' : ''}` : l.text
82 return next({ ...e, props: { ...e.props, tail: `ci ${word}` } })
83 })
84}
85types/index.d.ts 21 lines1// The light, as ci-light.mjs --json writes it.
2export type Light = {
3 repo: string | null
4 branch: string | null
5 state: 'red' | 'green' | 'running' | 'unknown' | 'none' | 'error'
6 since: string | null
7 pushes: number
8 more: boolean
9 running: boolean
10 workflows: string | null
11 error?: string
12 text: string
13 line: string
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'ci-light': { light: Light | null; hidden: string | null; told: string | null }
19 }
20}
21