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.

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