Holds `gh pr merge` until CI passes and one Codex review (OpenAI's luna model) has run. A PR line above the prompt, and /gate shows the PR's status.

<h1 align="center">Claude Code mods</h1>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>
<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>
Add the marketplace once, then install any mod by name:
claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods
Or install the general-purpose set in one go:
for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
claude plugin install "$m@claude-code-mods"
done
Restart Claude Code after installing. To try one without installing:
git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar
Needs Claude Code 2.1.287+. A few mods need more (Chrome,
gh, a connector); the tables say which.
| Mod | What it does | Command | |
|---|---|---|---|
| 🛰️ | mission-control | Live map of agents, tool calls and the code they touch | /mission |
| 📊 | context-bar | Your context window as one stacked bar, a color per category, with token counts and where it compacts | /context-bar |
| 🌦️ | token-weather | Context fill from Clear to Compact soon, plus a prompt-cache countdown | |
| ⏱️ | cache-clock | A prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+ | /cache-clock setup |
| 📍 | where-am-i | Goal, doing now, waiting on you, next step | /where |
| ➡️ | next-steps | 2 or 3 likely next prompts after each turn, one key to draft one | 1 2 3, 0 hides |
| 💰 | usage-meter | 5-hour and 7-day plan usage, the reset countdown and the session's cost | |
| 💳 | openai-balance | Your OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key | /openai-balance |
| 📡 | agent-radar | One live line per running subagent | /radar |
| 🔍 | review-watch | One live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends | |
| 🌐 | browser-lanes | Whether this session has a browser, and who holds it | /browser |
| 🕌 | prayer-times | The current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr | /prayers |
| 🎬 | replay-theater | Steps through the last turn's edits, one diff at a time | /replay |
| 📝 | md-preview | Renders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images | /md |
| Mod | What it does | Command | |
|---|---|---|---|
| 💥 | blast-radius | Holds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer |
| Mod | What it does | Command | |
|---|---|---|---|
| 🔀 | switchboard | Picks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost | /route |
These are built around my own tools and rules. Fork them and change the rules to yours.
| Mod | What it does | Command | |
|---|---|---|---|
| 👀 | glance | One line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors | /glance |
| 🚦 | merge-gate | Holds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours | /gate |
| 📏 | rulebook-guard | Enforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes | |
| 💾 | session-saver | Saves where you left off, shows it on resume. Needs unpause | /park [note] |
| Mod | What it does | Command | |
|---|---|---|---|
| 📱 | reels | YouTube Shorts while Claude works, pauses when it's done | /reels |
| 🐍 | snake | Snake while Claude works | /snake |
| 🎵 | now-playing | What Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app | /music |
<a id="mission-control"></a>
Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.
w shows the agents and every tool call, livec shows the code map, with import arrowsThe Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.
hooks/register.tsx 373 lines1// Merge Gate: CI, the Codex second-opinion review and the merge in one view.
2// - `codex review`: preflights ~/.codex/config.toml for the Ollama takeover, requires the
3// gpt-5.6-luna model, allows one pass per PR, and never `codex exec`.
4// - `gh pr merge`: holds the merge when CI is not green or Codex has not run, and asks.
5// - A band above the prompt for the current branch's PR, and /gate (/gate rerun).
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
8
9import type { GateStatus } from '../types'
10
11const LUNA = 'gpt-5.6-luna'
12
13// A command counts only where the shell starts one: at the start, after ; & | ( { or a backtick,
14// or on a new line. Before it may come VAR=x, wrappers (sudo, env, npx, timeout 900, nohup, xargs...),
15// shell keywords (if, then, !...), options and numbers; between the tool and its subcommand,
16// global options (codex -c k=v exec, gh -R o/r pr merge).
17// Words are bounded by [^\s;&|()] and the prefix is capped, so a long command cannot make the match slow.
18const START = String.raw`(?:^|[\n;&|({\x60])[ \t]*`
19const WORD = String.raw`[^\s;&|()\x60]`
20const WRAPPERS = 'sudo|env|npx|bunx|pnpx|time|timeout|nohup|nice|caffeinate|stdbuf|xargs|command|builtin|exec|if|elif|while|until|then|do|else|!'
21const PREFIX = String.raw`(?:(?:\w+=${WORD}*|(?:${WRAPPERS})(?![\w-])|-${WORD}+(?:[ \t]+[^\s;&|()\x60-]${WORD}*)?|\d[\w.]*)[ \t]+){0,8}`
22const OPTS = String.raw`(?:\s+-\S+(?:\s+[^\s;&|)\x60-]\S*)?)*`
23const at = (tool: string, ...sub: string[]) =>
24 new RegExp(START + PREFIX + String.raw`\\?(?:${WORD}*/)?` + tool + sub.map(s => OPTS + String.raw`\s+` + s).join('') + String.raw`(?![\w-])`)
25// A shell that runs the next string (bash -c, sh -lc, eval) or a heredoc (bash <<EOF).
26const RUNNER = /(?:^|[\s;&|({`])(?:(?:(?:ba|z|da|k)?sh)\s+(?:-\S+\s+)*-\w*c|eval)\s+$/
27const SHELL_HEREDOC = /(?:^|[\s;&|({`])(?:ba|z|da|k)?sh(?:\s+-\S+)*\s*$/
28const CODEX_EXEC = at('codex', 'exec')
29const CODEX_REVIEW = at('codex', 'review')
30const GH_MERGE = at('gh', 'pr', 'merge')
31
32// Held by the host, so the band survives a hot reload of this file.
33const status = atom({ plugin: 'merge-gate', key: 'status' } as const, null as GateStatus | null)
34
35type Pr = { number: number; title: string; branch: string }
36type Checks = { pass: number; fail: number; pending: number; failing: string[] }
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 const r = await next(e)
41 await $.command.register({ name: 'gate', description: "Show the PR's CI, Codex review and merge status; /gate rerun retries failed CI", argumentHint: '[rerun]' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
42 void refresh($).catch(() => {}) // background; a failed refresh just keeps the last band
43 return r
44 })
45
46 on('turn.complete', async ($, e, next) => {
47 const r = await next(e)
48 if (!e.agentId) void refresh($).catch(() => {}) // in the background, so the turn ends at once
49 return r
50 })
51
52 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
53 const shell = bare(e.command) // what the shell runs, without quoted text, heredoc bodies or comments
54 if (CODEX_EXEC.test(shell)) {
55 return { deny: 'merge-gate: `codex exec` can edit the branch. The rule is review only: use `codex review`.' }
56 }
57 if (CODEX_REVIEW.test(shell)) return codexReview($, e.command, () => next(e))
58 if (GH_MERGE.test(shell)) return merge($, shell, e.command, () => next(e))
59 return next(e)
60 })
61
62 on('command.run', { command: 'gate' }, async ($, e) => {
63 if (e.args.trim() === 'rerun') return { text: await rerun($) }
64 const s = await refresh($)
65 return { text: s ? describeStatus(s) : 'No open PR for this branch.' }
66 })
67
68 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
69 const rest = await next(e) // what other mods and Claude Code draw here stays
70 const s = await read($, status)
71 if (e.props.hasSurvey || !s) return rest
72
73 const { Box, Text } = $.ui.resolve(e)
74 const ci = s.fail > 0 ? 'red' : s.pending > 0 ? 'yellow' : 'green'
75 return (
76 <Box flexDirection="column">
77 <Text wrap="truncate-end">
78 <Text bold>{` ⛙ PR #${s.pr} `}</Text>
79 <Text color={ci}>
80 {s.fail > 0 ? `CI ✗ ${s.fail} failing` : s.pending > 0 ? `CI … ${s.pending} running` : `CI ✓ ${s.pass}`}
81 </Text>
82 <Text dimColor>{' · '}</Text>
83 <Text color={s.codex > 0 ? 'green' : 'yellow'}>{`Codex ${Math.min(s.codex, 1)}/1`}</Text>
84 {s.fail > 0 && <Text dimColor>{' · /gate rerun'}</Text>}
85 <Text dimColor>{` · ${s.title}`}</Text>
86 </Text>
87 {rest}
88 </Box>
89 )
90 })
91}
92
93async function codexReview($: EngineInterface, command: string, run: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
94 const home = (await $.env.get('HOME')) ?? ''
95 const config = await $.fs.read(`${home}/.codex/config.toml`).catch(() => '')
96 if (isOllama(config)) {
97 return {
98 deny:
99 "merge-gate: ~/.codex/config.toml points at Ollama (the Ollama app's ChatGPT toggle is on), so the review would go to a local model. " +
100 'Stop and ask the user to switch the toggle off. Do not edit the config.',
101 }
102 }
103 if (!/\bmodel\s*=\s*\\?["']?gpt-5\.6-luna["'\s]/.test(`${command} `)) {
104 return { deny: `merge-gate: Codex reviews run on ${LUNA} only. Add -c 'model="${LUNA}"' to the command.` }
105 }
106 const key = await codexKey($, await runDir($, command))
107 const done = Number((await $.store.get(key)) ?? 0)
108 if (done >= 1) {
109 return { deny: 'merge-gate: Codex already reviewed this PR once (rule: one pass per PR). Fix or answer its findings instead of running it again.' }
110 }
111 await $.store.set(key, done + 1) // counted when it starts: a quota error is not a reason to retry
112 void refresh($).catch(() => {}) // background; a failed refresh just keeps the last band
113 return run()
114}
115
116async function merge($: EngineInterface, shell: string, command: string, run: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
117 const dir = await runDir($, command)
118 const pr = await currentPr($, dir, prNumber(shell))
119 if (!pr) return run()
120 const checks = await ciChecks($, pr.number, dir)
121 const codex = Number((await $.store.get(await codexKey($, dir, pr))) ?? 0)
122 const missing = [
123 checks.fail > 0 && `CI has ${checks.fail} failing (${checks.failing.join(', ')})`,
124 checks.pending > 0 && `CI has ${checks.pending} still running`,
125 codex === 0 && 'Codex has not reviewed it',
126 ].filter((x): x is string => typeof x === 'string')
127 if (missing.length === 0) return run()
128
129 let answer = 'Hold'
130 try {
131 answer = await $.ui.ask(`merge-gate: PR #${pr.number} is not ready: ${missing.join('; ')}. Merge anyway?`, {
132 options: ['Hold', 'Merge anyway'],
133 header: 'Merge Gate',
134 })
135 } catch {
136 // no one to ask: hold
137 }
138 if (answer === 'Merge anyway') return run()
139 return { deny: `merge-gate: held the merge of PR #${pr.number}: ${missing.join('; ')}. Finish those first${checks.fail > 0 ? ' (failed CI: /gate rerun)' : ''}.` }
140}
141
142// The command as the shell reads it: heredoc bodies, quoted text and # comments blanked, so text
143// that merely mentions a command (a grep pattern, a commit message, a note) is not taken for one.
144// What the shell does run is kept: $( ) and backticks inside double quotes, the string given to
145// bash -c or eval, a heredoc fed to a shell, and a quoted number ("42").
146export function bare(command: string): string {
147 // The rest of the opener line (cat <<EOF | codex ...) is kept; only the body goes.
148 const text = command.replace(/<<-?[ \t]*(['"]?)(\w+)\1([^\n]*)\n([\s\S]*?)\n[ \t]*\2[ \t]*(?=\n|$)/g, (_m, _q, _tag, tail: string, body: string, at: number) =>
149 SHELL_HEREDOC.test(command.slice(command.lastIndexOf('\n', at - 1) + 1, at)) ? `${tail}\n${body}\n` : `${tail}\n`,
150 )
151 let i = 0
152
153 // A quoted string the shell runs as a command, or a quoted number: what to keep of it, if anything.
154 const keep = (out: string, inner: string) => (RUNNER.test(out) ? `(${bare(inner)})` : /^\d+$/.test(inner) ? inner : null)
155
156 // Plain shell, up to `stop` (the ) or backtick that closes a substitution) or the end.
157 const plain = (stop?: string): string => {
158 let out = ''
159 let depth = 0
160 while (i < text.length) {
161 const c = text[i]
162 if (c === stop && (stop !== ')' || depth === 0)) {
163 i++
164 return out
165 }
166 if (c === '$' && text[i + 1] === "'") {
167 let end = i + 2 // $'...' takes backslash escapes, \' among them
168 while (end < text.length && text[end] !== "'") end += text[end] === '\\' ? 2 : 1
169 out += "''"
170 i = end + 1
171 } else if (c === "'") {
172 const end = text.indexOf("'", i + 1) < 0 ? text.length : text.indexOf("'", i + 1)
173 out += keep(out, text.slice(i + 1, end)) ?? "''"
174 i = end + 1
175 } else if (c === '"') {
176 let end = i + 1
177 while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1
178 const kept = keep(out, text.slice(i + 1, end).replace(/\\(.)/g, '$1'))
179 i++
180 if (kept === null) out += `"${quoted()}"`
181 else {
182 out += kept
183 i = end + 1
184 }
185 } else if (c === '\\') {
186 out += text.slice(i, i + 2)
187 i += 2
188 } else if (c === '#' && (i === 0 || /[\s;&|()`]/.test(text[i - 1]))) {
189 const end = text.indexOf('\n', i)
190 i = end < 0 ? text.length : end
191 } else {
192 if (c === '(') depth++
193 if (c === ')') depth--
194 out += c
195 i++
196 }
197 }
198 return out
199 }
200
201 // Inside double quotes: the text is dropped, the substitutions are kept.
202 const quoted = (): string => {
203 let out = ''
204 while (i < text.length) {
205 const c = text[i]
206 if (c === '"') {
207 i++
208 return out
209 }
210 if (c === '\\') {
211 i += 2
212 } else if (c === '$' && text[i + 1] === '(') {
213 i += 2
214 out += `(${plain(')')})`
215 } else if (c === '`') {
216 i++
217 out += `\`${plain('`')}\``
218 } else {
219 i++
220 }
221 }
222 return out
223 }
224
225 return plain()
226}
227
228// The PR number given to `gh pr merge`, if any (options may come first).
229export function prNumber(shell: string) {
230 const m = GH_MERGE.exec(shell)
231 if (!m) return undefined
232 const rest = shell.slice(m.index + m[0].length)
233 return /^(?:\s+-\S+(?:\s+[^\s;&|)\x60-]\S*)?)*?\s+(\d+)(?![^\s;&|)\x60])/.exec(rest)?.[1]
234}
235
236// Ollama's ChatGPT toggle rewrites the config to a local server or an Ollama model.
237export function isOllama(config: string) {
238 const base = /^\s*openai_base_url\s*=\s*"([^"]*)"/m.exec(config)?.[1] ?? ''
239 const model = /^\s*model\s*=\s*"([^"]*)"/m.exec(config)?.[1] ?? ''
240 return /127\.0\.0\.1:11434|localhost:11434/.test(base) || /^(gemma|llama|qwen|mistral|deepseek|phi|gpt-oss)/i.test(model) || /:\d+b\b|:latest$/.test(model)
241}
242
243// The folders a command moves to before it runs the rest: each leading `cd X &&` or `cd X;`
244// (also inside a leading `(` or `{`), quoted or not, then the same inside a leading
245// `bash -c '...'` or `eval '...'`. Paths are returned as written, in order.
246const LEAD_CD = /^[\s({]*cd[ \t]+(?:'([^']*)'|"((?:[^"\\]|\\.)*)"|((?:[^\s;&|()'"`\\]|\\.)+))[ \t]*(?:&&|;|\n)/
247const LEAD_RUNNER = /^[\s({]*(?:(?:ba|z|da|k)?sh\s+(?:-\S+\s+)*-\w*c|eval)\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)")/
248export function cdTargets(command: string): string[] {
249 const dirs: string[] = []
250 let rest = command
251 for (;;) {
252 const m = LEAD_CD.exec(rest)
253 if (!m) break
254 dirs.push(m[1] ?? m[2]?.replace(/\\(.)/g, '$1') ?? m[3].replace(/\\(.)/g, '$1'))
255 rest = rest.slice(m[0].length)
256 }
257 const inner = LEAD_RUNNER.exec(rest)
258 if (inner) dirs.push(...cdTargets(inner[1] ?? inner[2].replace(/\\(.)/g, '$1')))
259 return dirs
260}
261
262// `to` resolved against the folder `from` (absolute), with `~` as `home`. Null when only the
263// shell knows: a variable or glob, `cd -` or another option, `~user`.
264export function resolveDir(from: string, to: string, home: string): string | null {
265 if (/[$`*?]/.test(to) || to.startsWith('-') || /^~[^/]/.test(to)) return null
266 const path = to === '~' || to.startsWith('~/') ? home + to.slice(1) : to.startsWith('/') ? to : `${from}/${to}`
267 const parts: string[] = []
268 for (const p of path.split('/')) {
269 if (p === '' || p === '.') continue
270 if (p === '..') parts.pop()
271 else parts.push(p)
272 }
273 return `/${parts.join('/')}`
274}
275
276// Where the command really runs: the session's folder, moved by any leading `cd`.
277async function runDir($: EngineInterface, command: string): Promise<string> {
278 let dir = await $.session.cwd()
279 const targets = cdTargets(command)
280 if (targets.length === 0) return dir
281 const home = (await $.env.get('HOME')) ?? ''
282 for (const t of targets) {
283 const next = resolveDir(dir, t, home)
284 if (next === null) break // `cd $X`, `cd -`: keep the last folder we know
285 dir = next
286 }
287 return dir
288}
289
290// One counter per PR (or per branch before it has one), shared across sessions and worktrees.
291// It is read from the folder the command runs in, not the session's, so a review started with
292// `cd <worktree> && codex review` counts for that worktree's PR. The repo is the main checkout
293// (git's common dir), the same for every worktree of it.
294async function codexKey($: EngineInterface, dir: string, known?: Pr | null) {
295 const pr = known ?? (await currentPr($, dir))
296 const common = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: dir }).catch(() => null)
297 const path = common?.exitCode === 0 ? common.stdout.trim() : ''
298 const repo = /^\/[^\n]*$/.test(path) ? path.replace(/\/\.git\/?$/, '') : dir // old git prints a relative path
299 if (pr) return `codex:${repo}#${pr.number}`
300 const branch = await $.process.run(['git', 'branch', '--show-current'], { cwd: dir }).catch(() => null)
301 return `codex:${repo}@${(branch?.exitCode === 0 && branch.stdout.trim()) || dir}` // detached HEAD: per folder
302}
303
304async function currentPr($: EngineInterface, dir?: string, number?: string): Promise<Pr | null> {
305 const args = ['gh', 'pr', 'view', ...(number ? [number] : []), '--json', 'number,title,headRefName,state']
306 const r = await $.process.run(args, { timeoutMs: 15_000, ...(dir ? { cwd: dir } : {}) }).catch(() => null)
307 if (!r || r.exitCode !== 0) return null
308 try {
309 const o = JSON.parse(r.stdout) as { number: number; title: string; headRefName: string; state: string }
310 if (o.state !== 'OPEN') return null
311 return { number: o.number, title: o.title, branch: o.headRefName }
312 } catch {
313 return null
314 }
315}
316
317export function countChecks(json: string): Checks {
318 try {
319 const rows = JSON.parse(json) as { name: string; bucket: string }[]
320 return {
321 pass: rows.filter(c => c.bucket === 'pass' || c.bucket === 'skipping').length,
322 fail: rows.filter(c => c.bucket === 'fail' || c.bucket === 'cancel').length,
323 pending: rows.filter(c => c.bucket === 'pending').length,
324 failing: rows.filter(c => c.bucket === 'fail' || c.bucket === 'cancel').map(c => c.name),
325 }
326 } catch {
327 return { pass: 0, fail: 0, pending: 0, failing: [] }
328 }
329}
330
331async function ciChecks($: EngineInterface, pr: number, dir?: string) {
332 const r = await $.process.run(['gh', 'pr', 'checks', String(pr), '--json', 'name,bucket'], { timeoutMs: 15_000, ...(dir ? { cwd: dir } : {}) }).catch(() => null)
333 return countChecks(r?.stdout ?? '[]') // gh exits non-zero while checks fail or run; the JSON is still there
334}
335
336async function refresh($: EngineInterface): Promise<GateStatus | null> {
337 const pr = await currentPr($)
338 if (!pr) {
339 await update($, status, () => null)
340 return null
341 }
342 const checks = await ciChecks($, pr.number)
343 const codex = Number((await $.store.get(await codexKey($, await $.session.cwd(), pr))) ?? 0)
344 const s: GateStatus = { pr: pr.number, title: pr.title, branch: pr.branch, ...checks, codex }
345 await update($, status, () => s)
346 return s
347}
348
349async function rerun($: EngineInterface) {
350 const pr = await currentPr($)
351 if (!pr) return 'No open PR for this branch.'
352 const list = await $.process.run(['gh', 'run', 'list', '--branch', pr.branch, '--json', 'databaseId,conclusion,name', '-L', '20'], { timeoutMs: 15_000 })
353 const runs = (JSON.parse(list.stdout || '[]') as { databaseId: number; conclusion: string; name: string }[]).filter(r => r.conclusion === 'failure')
354 if (runs.length === 0) return `No failed runs on ${pr.branch}.`
355 const done: string[] = []
356 for (const r of runs) {
357 const x = await $.process.run(['gh', 'run', 'rerun', String(r.databaseId), '--failed'], { timeoutMs: 30_000 })
358 done.push(`${r.name}: ${x.exitCode === 0 ? 'retriggered' : `failed (${x.stderr.trim().slice(0, 80)})`}`)
359 }
360 void refresh($).catch(() => {}) // background; a failed refresh just keeps the last band
361 return `Reran failed jobs on PR #${pr.number}:\n${done.map(d => `- ${d}`).join('\n')}`
362}
363
364export function describeStatus(s: GateStatus) {
365 const ready = s.fail === 0 && s.pending === 0 && s.codex > 0
366 return [
367 `PR #${s.pr}: ${s.title}`,
368 `- CI: ${s.pass} passing, ${s.fail} failing${s.failing.length ? ` (${s.failing.join(', ')})` : ''}, ${s.pending} running`,
369 `- Codex review: ${s.codex > 0 ? 'done' : 'not run yet'}`,
370 `- ${ready ? 'Ready to merge' : 'Not ready to merge'}`,
371 ].join('\n')
372}
373types/index.d.ts 17 lines1export type GateStatus = {
2 pr: number
3 title: string
4 branch: string
5 pass: number
6 fail: number
7 pending: number
8 failing: string[]
9 codex: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'merge-gate': { status: GateStatus | null }
15 }
16}
17