SLOPSHOPPER

merge-gate

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.

newbandguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · merge-gate
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gate ⎿ merge-gate: No open PR for this branch. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in 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.

Source 3 files
hooks/register.tsx 385 lines
1// 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}
385
hooks/bands.ts 73 lines
1// 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}
73
types/index.d.ts 17 lines
1export 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