SLOPSHOPPER

wipnote-ci

A side pane that follows a pull request's CI live through wipnote ci status: checks failed first with elapsed time, review, branch health, base, release and…

newpanecommandtoaststatusprocess
★ 4v0.1.0MITupdated 2026-10-26shakestzd/wipnote/mods/wipnote-ci
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wipnote-ci
│ ┃ CI ✕ › fix the failing auth test and add an audit log call │ ┃ No pull request is followed. │ ┃ /ci-pane follows the pull request of this ⏺ Read(src/auth.ts) │ ┃ branch. ⎿ Read 6 lines │ ┃ /ci-pane 85 follows PR 85, /ci-pane <branch> ⏺ Update(src/auth.ts) │ ┃ that branch's, /ci-pane stop ends it. ⎿ 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 │ │ › /ci-pane │ ⎿ wipnote-ci: CI pane: unreadable output from wipnote ci status. I │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · CI
No pull request is followed. /ci-pane follows the pull request of this branch. /ci-pane 85 follows PR 85, /ci-pane <branch> that branch's, /ci-pane stop ends it.
README

wipnote-ci (mod)

A Claude Code mod that follows a pull request's CI live in a pane beside the chat. It is a thin view over wipnote ci status --json: every GitHub read happens in the Go CLI (internal/gitwatch), so the mod makes no gh calls and every harness shares the same logic.

CI pane
PR #85 CI pane over the CLI
feat/ci-pane → main · open · feat-8354cf43
1 passed · 1 failed · 1 running · 1 skipped

✗ go-test 3:00
https://example.test/run/2
● lint 5:00
✓ build 4:10
– docs

review: approved · 2 open threads · merge blocked · requested core-team
branch feat/ci-pane: 2 ahead, 1 behind origin/main · 3 dirty · 1 unpushed
stale worktree old-pane (feat/old-pane): merged, idle 9d
base main: red
  ✗ Release (failure)
    https://example.test/run/9
release v0.82.1 (2026-10-20), 14 commits since · release: green
note: release: gh release view: HTTP 502

live, every 15 s · read 23:05:00

What it does

  • /ci-pane follows the current branch's pull request (else your newest open one), /ci-pane 85 pull request 85, /ci-pane <branch> that branch's (the name follows /plan-review-pane; /wipnote:ci is the plugin command that answers in the chat). The argument goes to wipnote ci status unchanged; the CLI resolves it.
  • Polls at wipnote ci watch's cadence: every 15 s while any check is pending, otherwise every 60 s. It stops once settled: at least one check and none pending, the PR merged or closed, or no PR matched. An open PR with no checks is watched for a 2-minute grace from the first read (NO_CHECKS_GRACE_MS, as ci watch --no-checks-grace), the last wait cut short so the grace ends on a fresh read; still none then, it settles showing no checks reported. Checks that appear inside the grace are followed as normal. The elapsed time of each check ticks every second.
  • Shows the title and PR line with the linked work item, the counts, the checks (failed first, each failure with its link), review (decision, open threads, merge state or computing, requested reviewers), branch health (ahead/behind, dirty, unpushed, stale worktrees), the base branch's CI with its failing workflows, the latest release, and the CLI's notes.
  • available: false (gh missing, signed out, a failed read) shows the reason dimmed and keeps polling every 60 s. Output it cannot read keeps the last snapshot and adds a last read failed line.
  • Status line: CI #85: 3/5 passed · 1 failed · 1 running. Toast when it settles: CI #85: 2 failed, PR #85 merged, CI #85: no checks reported or CI #85: all 5 checks finished.
  • /ci-pane stop cancels both timers (the poll and the one-second tick) and clears the status line; the pane keeps the last read, marked stopped. Closing the pane (ui.close) does the same; /ci-pane opens it and starts again.

Files

.claude-plugin/plugin.json   manifest (name, types contract)
hooks/hooks.json             { "modules": ["./register.tsx"] }
hooks/register.tsx           hooks: session.start (/ci-pane), command.run, ui.close, session.end, ui.render (Pane); the two timers
hooks/pane.tsx               the pane's tree, section by section
hooks/format.ts              pure helpers: parse, fail-first sort, elapsed time, settle rule, cadence, section lines
types/index.d.ts             the ci status JSON and the $.state contract (PluginState['wipnote-ci'])
prompt.md                    the one-line rule `wipnote claude` appends to the system prompt
tests/*.test.ts(x)           claude plugin test suites; tests/fixtures holds snapshot JSON

Run it

wipnote claude loads it with every mod in mods/; WIPNOTE_NO_MODS=1 skips the mods. It runs wipnote from PATH. On its own:

claude --plugin-dir mods/wipnote-ci

Check it

claude plugin validate mods/wipnote-ci
claude plugin test mods/wipnote-ci     # 26 tests
tsc -p mods/wipnote-ci                 # after a load lays .claude-plugin/types/
Source 4 files
hooks/register.tsx 132 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CiView, Snapshot } from '../types'
5import { failReason, nextWait, noChecksExpired, parseSnapshot, settled, summary, toastText } from './format'
6import { paneTree } from './pane'
7
8const PANE = 'wipnote-ci'
9const EMPTY: CiView = { arg: null, snap: null, fetchedAt: 0, error: '', done: false, since: 0, noChecks: false }
10
11// Change a tag when the fields of its value change: it declines what an
12// earlier version stored, which outlives a reload.
13const view = atom({ plugin: 'wipnote-ci', key: 'view' } as const, EMPTY, { shape: 'view-2' })
14const clock = atom({ plugin: 'wipnote-ci', key: 'now' } as const, 0, { shape: 'now-1' })
15
16// The next read (rescheduled after each one, at the watch cadence) and the
17// one-second tick of the elapsed times. `run` numbers each /ci-pane, so a read
18// still in flight when /ci-pane stop, a close or a new /ci-pane lands schedules nothing.
19let poll: Timer | undefined
20let tick: Timer | undefined
21let run = 0
22
23function stop() {
24  run += 1
25  poll?.cancel()
26  tick?.cancel()
27  poll = undefined
28  tick = undefined
29}
30
31/** One `wipnote ci status --json`: the snapshot, or why there is none. */
32async function readSnapshot($: EngineInterface, arg: string): Promise<Snapshot | string> {
33  const argv = arg === '' ? ['wipnote', 'ci', 'status', '--json'] : ['wipnote', 'ci', 'status', arg, '--json']
34  const ran = await $.process.run(argv, { timeoutMs: 60_000 }).catch(() => undefined)
35  if (ran === undefined) return 'wipnote unavailable'
36  const snap = parseSnapshot(ran.stdout)
37  if (snap !== undefined) return snap
38  return ran.exitCode !== 0 ? failReason(ran.stderr, ran.exitCode) : 'unreadable output from wipnote ci status'
39}
40
41/** Reads once into the view; a failed read keeps the last snapshot. A read
42 *  that /ci-pane stop or a close overtook writes nothing. */
43async function refresh($: EngineInterface, arg: string, mine: number): Promise<CiView> {
44  const got = await readSnapshot($, arg)
45  if (mine !== run) return read($, view)
46  const now = await $.clock.now()
47  const before = await read($, view)
48  if (typeof got === 'string') {
49    return update($, view, old => ({ ...old, fetchedAt: now, error: got.slice(0, 200) }))
50  }
51  const since = before.since === 0 ? now : before.since
52  const noChecks = noChecksExpired(got, since, now)
53  const done = noChecks || settled(got)
54  const next = await update($, view, () => ({ arg, snap: got, fetchedAt: now, error: '', done, since, noChecks }))
55  await update($, clock, () => now)
56  if (got.pr !== undefined) await $.ui.status(summary(got))
57  const toast = toastText(got, noChecks)
58  if (done && !before.done && toast !== undefined) await $.ui.toast(toast)
59  return next
60}
61
62/** Reads, then waits the cadence the read calls for, until settled or stopped. */
63async function cycle($: EngineInterface, arg: string, mine: number) {
64  const v = await refresh($, arg, mine)
65  if (mine !== run) return
66  if (v.done) {
67    stop()
68    return
69  }
70  poll = $.clock.after(nextWait(v.snap, v.since, await $.clock.now()), () => void cycle($, arg, mine).catch(() => undefined))
71}
72
73async function follow($: EngineInterface, arg: string): Promise<string> {
74  stop()
75  const mine = run
76  await update($, view, () => ({ ...EMPTY, arg }))
77  await $.ui.open({ id: PANE, title: 'CI' })
78  await cycle($, arg, mine)
79  const v = await read($, view)
80  if (mine === run && !v.done) {
81    tick = $.clock.every(1000, () => void $.clock.now().then(now => update($, clock, () => now)).catch(() => undefined))
82  }
83  const s = v.snap
84  if (s === null) return `CI pane: ${v.error}. It tries again in a minute.`
85  if (!s.available) return `CI status not available: ${s.reason ?? 'unknown'}. The pane keeps trying.`
86  if (s.pr === undefined) return `No pull request found: ${s.reason ?? 'none matched'}.`
87  return `Following CI of PR #${s.pr.number} in the CI pane.`
88}
89
90/** /ci-pane stop and a close: both timers cancelled, the status line cleared, the last read kept. */
91async function halt($: EngineInterface) {
92  stop()
93  await update($, view, old => ({ ...old, arg: null }))
94  await $.ui.status(undefined)
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    await $.command.register({
100      name: 'ci-pane',
101      description: 'Follow a pull request\'s CI live in a pane: /ci-pane, /ci-pane 85, /ci-pane <branch>, /ci-pane stop',
102      argumentHint: '[pr|branch|stop]',
103    })
104    return next(e)
105  })
106
107  on('session.end', async ($, e, next) => {
108    stop()
109    return next(e)
110  })
111
112  on('command.run', { command: 'ci-pane' }, async ($, e) => {
113    const arg = e.args.trim()
114    if (arg !== 'stop') return { text: await follow($, arg) }
115    await halt($)
116    return { text: 'CI pane stopped. /ci-pane starts it again.' }
117  })
118
119  // Closing the pane (the person's mark or key, a plugin, an unload) ends the
120  // following as /ci-pane stop does; /ci-pane opens it and starts again.
121  on('ui.close', {}, async ($, e, next) => {
122    if (e.id === PANE) await halt($).catch(() => undefined)
123    return next(e)
124  })
125
126  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
127    const v = await read($, view)
128    const now = (await read($, clock)) || v.fetchedAt
129    return paneTree($.ui.resolve(e), v, now, e.props.bodyColumns)
130  })
131}
132
hooks/format.ts 171 lines
1import type { Base, Branch, Check, Release, Review, Snapshot, Worktree } from '../types'
2
3export const RANK: Record<string, number> = { fail: 0, cancel: 1, pending: 2, pass: 3, skipping: 4 }
4export const ICON: Record<string, string> = { pass: '✓', fail: '✗', pending: '●', cancel: '–', skipping: '–' }
5export const COLOR: Record<string, string> = {
6  pass: 'success',
7  fail: 'error',
8  pending: 'warning',
9  cancel: 'inactive',
10  skipping: 'inactive',
11}
12
13/** The cadence of `wipnote ci watch`: checks while one is pending, else the slow sections. */
14export const CHECKS_MS = 15_000
15export const SLOW_MS = 60_000
16/** How long from the first read an open PR with no checks is still watched for them (ci watch --no-checks-grace). */
17export const NO_CHECKS_GRACE_MS = 120_000
18/** The note ci watch ends such a PR with. */
19export const NO_CHECKS_NOTE = 'no checks reported'
20
21const CLOSED = new Set(['MERGED', 'CLOSED'])
22
23/** Reads `wipnote ci status --json`; undefined when it is not a snapshot. */
24export function parseSnapshot(stdout: string): Snapshot | undefined {
25  try {
26    const raw = JSON.parse(stdout) as Snapshot
27    if (typeof raw !== 'object' || raw === null || typeof raw.available !== 'boolean') return undefined
28    return raw
29  } catch {
30    return undefined
31  }
32}
33
34/** Failed first, then cancelled, running, passed, skipped; by name within each. */
35export function sortChecks(checks: Check[] | null | undefined): Check[] {
36  return [...(checks ?? [])].sort(
37    (a, b) => (RANK[a.bucket] ?? 9) - (RANK[b.bucket] ?? 9) || a.name.localeCompare(b.name),
38  )
39}
40
41/** A time GitHub gave, or null for none (a check not finished can read as year 1). */
42export function time(text: string | null | undefined): number | null {
43  if (!text) return null
44  const ms = Date.parse(text)
45  return Number.isNaN(ms) || ms < Date.UTC(2000, 0, 1) ? null : ms
46}
47
48/** "m:ss" from the check's start to its end, or to now while it runs. */
49export function span(check: Check, now: number): string {
50  const start = time(check.started_at)
51  if (start === null) return ''
52  const end = time(check.completed_at) ?? now
53  const seconds = Math.max(0, Math.round((end - start) / 1000))
54  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
55}
56
57export function cut(text: string, width: number): string {
58  return text.length <= width ? text : `${text.slice(0, Math.max(1, width - 1))}…`
59}
60
61/** The status line: "CI #85: 3/5 passed · 1 failed · 1 running". */
62export function summary(s: Snapshot): string {
63  const n = s.counts
64  const parts = [`${n.pass}/${n.total} passed`]
65  if (n.fail > 0) parts.push(`${n.fail} failed`)
66  if (n.pending > 0) parts.push(`${n.pending} running`)
67  return `CI #${s.pr?.number ?? '?'}: ${parts.join(' · ')}`
68}
69
70/** "branch → base · state · work item", under the title. */
71export function prLine(s: Snapshot): string {
72  if (s.pr === undefined) return ''
73  const parts = [`${s.pr.branch} → ${s.pr.base}`, s.pr.state.toLowerCase()]
74  if (s.work_item !== '') parts.push(s.work_item)
75  return parts.join(' · ')
76}
77
78/** Settled as `wipnote ci watch` settles: the PR merged or closed, or at
79 *  least one check and none pending; also when no PR matched. Never while
80 *  wipnote cannot read GitHub. */
81export function settled(s: Snapshot): boolean {
82  if (!s.available) return false
83  if (s.pr === undefined) return true
84  if (CLOSED.has(s.pr.state)) return true
85  return s.counts.total > 0 && s.counts.pending === 0
86}
87
88/** How long until the next read. */
89export function interval(s: Snapshot | null): number {
90  return s !== null && s.counts.pending > 0 ? CHECKS_MS : SLOW_MS
91}
92
93/** An open PR still without a check once the grace from `since` has run out. */
94export function noChecksExpired(s: Snapshot, since: number, now: number): boolean {
95  if (!s.available || s.pr === undefined || CLOSED.has(s.pr.state)) return false
96  return s.counts.total === 0 && now - since >= NO_CHECKS_GRACE_MS
97}
98
99/** The wait before the next read, cut short at the grace end while there are no checks. */
100export function nextWait(s: Snapshot | null, since: number, now: number): number {
101  const wait = interval(s)
102  if (s === null || !s.available || s.pr === undefined || s.counts.total > 0) return wait
103  const left = since + NO_CHECKS_GRACE_MS - now
104  return left > 0 ? Math.min(wait, left) : wait
105}
106
107/** The toast when a followed PR settles; none without a PR. */
108export function toastText(s: Snapshot, noChecks = false): string | undefined {
109  if (s.pr === undefined) return undefined
110  const n = s.pr.number
111  if (noChecks) return `CI #${n}: ${NO_CHECKS_NOTE}`
112  if (s.counts.fail > 0) return `CI #${n}: ${s.counts.fail} failed`
113  if (s.pr.state === 'MERGED') return `PR #${n} merged`
114  return `CI #${n}: all ${s.counts.total} checks finished`
115}
116
117/** "review: approved · 2 open threads · merge blocked · requested team". */
118export function reviewLine(r: Review): string {
119  const merge = r.mergeable === 'computing' ? 'computing' : r.merge_state.toLowerCase()
120  let line = `review: ${r.decision.toLowerCase()} · ${r.threads_open} open threads · merge ${merge}`
121  if (r.requested && r.requested.length > 0) line += ` · requested ${r.requested.join(', ')}`
122  return line
123}
124
125/** "branch x: 2 ahead, 1 behind origin/main · 3 dirty · 1 unpushed". */
126export function branchLine(b: Branch): string {
127  const where = b.base !== '' ? `${b.ahead} ahead, ${b.behind} behind ${b.base}` : b.sync
128  const parts = [`branch ${b.name}: ${where}`, b.dirty > 0 ? `${b.dirty} dirty` : 'clean']
129  if (b.unpushed > 0) parts.push(`${b.unpushed} unpushed`)
130  return parts.join(' · ')
131}
132
133/** One line per stale worktree, named by its folder. */
134export function staleLines(worktrees: Worktree[] | null | undefined): string[] {
135  return (worktrees ?? [])
136    .filter(w => (w.stale ?? []).length > 0)
137    .map(w => {
138      const reasons = (w.stale ?? []).map(r => (r === 'idle' ? `idle ${w.idle_days}d` : r))
139      const folder = w.path.split('/').filter(p => p !== '').pop() ?? w.path
140      return `stale worktree ${folder}${w.branch ? ` (${w.branch})` : ''}: ${reasons.join(', ')}`
141    })
142}
143
144export type Line = { text: string; bad: boolean }
145
146/** The base line, then each failing workflow and its link. */
147export function baseLines(b: Base): Line[] {
148  const lines: Line[] = [{ text: `base ${b.name}: ${b.state}`, bad: b.state === 'red' }]
149  for (const f of b.failing ?? []) {
150    lines.push({ text: `  ✗ ${f.workflow} (${f.conclusion})`, bad: true })
151    if (f.url !== '') lines.push({ text: `    ${f.url}`, bad: false })
152  }
153  return lines
154}
155
156/** "release v1 (2026-10-20), 14 commits since · release: green", as ci status prints it. */
157export function releaseLine(r: Release): string {
158  if (r.state === 'none') return 'release: none'
159  let line = `release ${r.tag ?? ''}`
160  if (r.published_at) line += ` (${r.published_at.slice(0, 10)})`
161  line += r.commits_since !== undefined ? `, ${r.commits_since} commits since` : ', commits since unknown'
162  if (r.workflow) line += ` · ${r.workflow.name}: ${r.workflow.state}`
163  return line
164}
165
166/** The first line of a failed run, short enough for the pane. */
167export function failReason(stderr: string, exitCode: number): string {
168  const line = stderr.split('\n').map(l => l.trim()).find(l => l !== '')
169  return (line ?? `wipnote exited ${exitCode}`).replace(/^Error:\s*/, '').slice(0, 160)
170}
171
hooks/pane.tsx 113 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { CiView, Snapshot } from '../types'
4import {
5  COLOR, ICON, NO_CHECKS_NOTE, baseLines, branchLine, cut, interval, prLine, releaseLine, reviewLine, sortChecks, span, staleLines,
6} from './format'
7
8type UI = ReturnType<EngineInterface['ui']['resolve']>
9
10function Idle({ Box, Text }: UI) {
11  return (
12    <Box flexDirection="column">
13      <Text dimColor>No pull request is followed.</Text>
14      <Text dimColor>/ci-pane follows the pull request of this branch.</Text>
15      <Text dimColor>/ci-pane 85 follows PR 85, /ci-pane &lt;branch&gt; that branch's, /ci-pane stop ends it.</Text>
16    </Box>
17  )
18}
19
20function Head({ Box, Text }: UI, s: Snapshot, width: number) {
21  if (!s.available || s.pr === undefined) {
22    return (
23      <Box flexDirection="column">
24        <Text weight="bold">{s.available ? 'No pull request' : 'CI status not available'}</Text>
25        <Text dimColor wrap="truncate-end">{s.reason ?? ''}</Text>
26      </Box>
27    )
28  }
29  const n = s.counts
30  return (
31    <Box flexDirection="column">
32      <Text weight="bold" wrap="truncate-end">{`PR #${s.pr.number} ${cut(s.pr.title, width - 10)}`}</Text>
33      <Text dimColor wrap="truncate-end">{prLine(s)}</Text>
34      <Text>
35        <Text color="success">{`${n.pass} passed`}</Text>
36        {' · '}
37        <Text color={n.fail > 0 ? 'error' : 'inactive'}>{`${n.fail} failed`}</Text>
38        {' · '}
39        <Text color={n.pending > 0 ? 'warning' : 'inactive'}>{`${n.pending} running`}</Text>
40        {n.other > 0 ? <Text dimColor>{` · ${n.other} skipped`}</Text> : null}
41      </Text>
42    </Box>
43  )
44}
45
46// Every check goes in the tree: the pane's body scrolls a taller one.
47function Checks({ Box, Text }: UI, v: CiView, s: Snapshot, now: number, width: number) {
48  if (s.pr === undefined) return null
49  const sorted = sortChecks(s.checks)
50  if (sorted.length === 0) return <Text dimColor>{v.noChecks ? NO_CHECKS_NOTE : 'No checks reported yet.'}</Text>
51  return (
52    <Box flexDirection="column">
53      {sorted.map((c, i) => {
54        const took = span(c, now)
55        return (
56          <Box key={`c${i}`} flexDirection="column">
57            <Text color={COLOR[c.bucket] ?? 'inactive'} wrap="truncate-end">
58              {`${ICON[c.bucket] ?? '?'} ${cut(c.name, width - 10)}`}
59              {took !== '' ? <Text dimColor tabular>{` ${took}`}</Text> : null}
60            </Text>
61            {c.bucket === 'fail' && c.link !== '' ? <Text dimColor wrap="truncate-end">{c.link}</Text> : null}
62          </Box>
63        )
64      })}
65    </Box>
66  )
67}
68
69/** Review, branch, stale worktrees, base, release and notes: one dim line each. */
70function Sections({ Box, Text }: UI, s: Snapshot) {
71  const lines: { text: string; bad: boolean }[] = []
72  if (s.review) lines.push({ text: reviewLine(s.review), bad: false })
73  if (s.branch) lines.push({ text: branchLine(s.branch), bad: false })
74  for (const t of staleLines(s.worktrees)) lines.push({ text: t, bad: false })
75  if (s.base) lines.push(...baseLines(s.base))
76  if (s.release) lines.push({ text: releaseLine(s.release), bad: false })
77  for (const n of s.notes ?? []) lines.push({ text: `note: ${n}`, bad: false })
78  if (lines.length === 0) return null
79  return (
80    <Box flexDirection="column">
81      {lines.map((l, i) => (
82        <Text key={`l${i}`} wrap="truncate-end" color={l.bad ? 'error' : undefined} dimColor={!l.bad}>{l.text}</Text>
83      ))}
84    </Box>
85  )
86}
87
88function footer(v: CiView, s: Snapshot): string {
89  const at = v.fetchedAt === 0 ? 'not yet' : new Date(v.fetchedAt).toTimeString().slice(0, 8)
90  if (v.arg === null) return `stopped · read ${at} · /ci-pane starts it again`
91  if (v.done) return `${s.pr === undefined ? 'nothing to follow' : 'finished'} · read ${at}`
92  return `live, every ${interval(s) / 1000} s · read ${at}`
93}
94
95/** The pane's tree for the view, drawn on the surface's elements. */
96export function paneTree(ui: UI, v: CiView, now: number, width: number) {
97  const { Box, Text } = ui
98  const s = v.snap
99  if (s === null) return Idle(ui)
100  return (
101    <Box flexDirection="column">
102      {Head(ui, s, width)}
103      <Text> </Text>
104      {Checks(ui, v, s, now, width)}
105      <Text> </Text>
106      {Sections(ui, s)}
107      <Text> </Text>
108      <Text dimColor>{footer(v, s)}</Text>
109      {v.error !== '' ? <Text color="error" wrap="truncate-end">{`last read failed: ${v.error}`}</Text> : null}
110    </Box>
111  )
112}
113
types/index.d.ts 103 lines
1// The JSON `wipnote ci status [pr|branch] --json` prints (internal/gitwatch:
2// Snapshot and its sections). Go's nil slices arrive as null.
3
4export type Check = {
5  name: string
6  workflow: string
7  /** pass, fail, pending, skipping or cancel */
8  bucket: string
9  required: boolean
10  started_at: string | null
11  completed_at: string | null
12  link: string
13}
14
15export type Counts = { pass: number; fail: number; pending: number; other: number; total: number }
16
17export type PR = { number: number; title: string; branch: string; base: string; state: string; url: string; base_oid?: string }
18
19export type Review = {
20  decision: string
21  requested: string[] | null
22  threads_open: number
23  threads_outdated: number
24  /** "computing" while GitHub is still working out mergeability */
25  mergeable: string
26  merge_state: string
27}
28
29export type BaseRun = { workflow: string; head_sha: string; status: string; conclusion: string; url: string; created_at: string }
30
31export type Base = { name: string; state: string; base_red_too: boolean; failing: BaseRun[] | null; runs: BaseRun[] | null }
32
33export type ReleaseWorkflow = { name: string; state: string; status?: string; conclusion?: string; url?: string; created_at?: string }
34
35export type Release = {
36  state: string
37  tag?: string
38  published_at?: string
39  url?: string
40  commits_since?: number
41  workflow?: ReleaseWorkflow
42}
43
44export type Branch = {
45  name: string
46  base: string
47  ahead: number
48  behind: number
49  dirty: number
50  unpushed: number
51  upstream?: string
52  sync: string
53}
54
55export type Worktree = {
56  path: string
57  branch?: string
58  main?: boolean
59  locked?: boolean
60  prunable?: boolean
61  idle_days: number
62  stale?: string[] | null
63}
64
65export type Snapshot = {
66  available: boolean
67  reason?: string
68  pr?: PR
69  work_item: string
70  checks: Check[] | null
71  counts: Counts
72  review?: Review
73  base?: Base
74  release?: Release
75  fetched_at: string
76  branch?: Branch
77  worktrees?: Worktree[] | null
78  notes?: string[] | null
79}
80
81/** What the pane follows and the last snapshot it read. */
82export type CiView = {
83  /** The argument /ci-pane was given ('' for the current branch); null before the first /ci-pane, after /ci-pane stop or a close */
84  arg: string | null
85  snap: Snapshot | null
86  /** When the last read finished, in milliseconds since the epoch */
87  fetchedAt: number
88  /** The last read's problem, empty when it worked */
89  error: string
90  /** Settled (no check pending, the PR merged or closed, no PR matched, or no checks after the grace): polling has stopped */
91  done: boolean
92  /** When the first read of this /ci-pane finished: the no-checks grace runs from here */
93  since: number
94  /** Settled because the PR still had no checks when the grace ran out */
95  noChecks: boolean
96}
97
98declare module 'claude-code' {
99  interface PluginState {
100    'wipnote-ci': { view: Shaped<CiView>; now: Shaped<number> }
101  }
102}
103