SLOPSHOPPER

git-band

입력창 위에 프로젝트 · 브랜치 · 변경 · 동기화 · CI 상태를 한 줄로 보여 준다

newbandcommandprocesstimer
v0.1.0no licenseupdated 2026-10-06dbsrjs/Nexus/.claude/mods/git-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-band
› 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 › /git-band ⎿ git-band: git 상태를 새로 고쳤습니다. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Nexus

개발자를 위한 커뮤니케이션 허브. 대화 · 파일 · 이슈 · 저장소 · AI 를 한 화면에 모은다.

일반 메신저는 개발 맥락을 모르고, 개발 도구는 대화를 담지 못한다. Nexus 는 그 사이를 메운다.

  • 커밋 · PR · 푸시 이벤트가 채널 안으로 흘러 들어온다
  • 대화 중에 그 자리에서 이슈를 만든다 — 원문 링크가 남는다
  • AI 가 대화와 코드를 같은 맥락으로 읽는다 — 인덱싱된 저장소를 근거로 답하고 출처를 인용한다
  • 오프라인에서도 동작한다 — 캐시로 대화를 보여 주고, 쓴 메시지는 재연결 때 내보낸다

기능

영역내용
대화스페이스 · 카테고리 · 채널(공개/비공개) · 실시간 전송 · 스레드 · 답장(인용) · 멘션 · 리액션 · 핀 · 마크다운
DM · 프레즌스스페이스 안 1:1 DM · 온라인 / 자리비움(10분 무입력) / 오프라인 · 입력 중 표시
멤버 · 권한스페이스 만들기 · 초대 코드 · 초대 링크 · 역할(owner · admin · member · guest) · 비공개 채널 명단 · 역할별 채널 권한(가리기 · 읽기 전용)
알림알림함(멘션 · @channel · DM · 내 글의 답글) · 종류별 스위치 · 채널 음소거 · 앱을 보고 있지 않을 때 OS 알림(Windows 토스트 · 브라우저) · Windows 트레이
파일첨부 업로드(진행률) · 이미지 미리보기 · 스페이스 파일 목록 · 무기한 보관
이슈칸반 보드(끌어 옮기기) · 상세 · 댓글 · 라벨 · 대화 → 이슈 · 스프린트 · 번다운(스페이스마다 켜는 선택 기능)
GitHub계정 연결(OAuth) · 웹훅 자동 등록 · 브랜치 · 파일 트리 · 커밋 · PR 열람
인덱싱저장소를 청크로 나눠 임베딩 · 벡터 검색(pgvector HNSW) · push 마다 증분 갱신
AI 패널자유 지시문 + 프리셋(요약 · 이슈 초안) · 컨텍스트(메시지 · 채널 최근 대화 · 저장소 RAG) · 이어 묻기 · 지난 대화 다시 열기
설정표시 이름 · 프로필 사진 · 비밀번호 변경 · 알림 · 테마(시스템 · 라이트 · 다크)
화면자체 UI — Material · Cupertino 없이 직접 만든 부품(app/lib/ui/) · 반응형(데스크톱 · 태블릿 · 모바일) · 새로고침 · 로그인 뒤 원래 주소로

아키텍처

app/     Flutter — 한 코드베이스로 Windows · Android · Web (iOS 는 macOS 가 없어 동결)
   │     Riverpod · go_router · dio · drift(오프라인 캐시 + 전송 큐) · 자체 UI(WidgetsApp)
   │
   │  REST + Socket.IO
   ▼
server/  NestJS + Prisma
   ├─ PostgreSQL + pgvector   모든 데이터 · 코드 임베딩 · 작업 큐(AI · 인덱싱)
   ├─ 스토리지                첨부 파일 (개발은 로컬 디스크, 배포는 S3 호환 — R2)
   ├─ LLM                     gemini · local(Ollama) · fake
   └─ 임베딩                  gemini · local(Ollama) · fake

deploy/  VM 한 대 — Docker Compose(postgres · server · nginx · cloudflared · backup)
         웹과 API 를 nginx 한 오리진으로 낸다 · Cloudflare Tunnel · R2

Space 가 모든 데이터의 루트인 멀티테넌트 구조다. 채널 · 메시지 · 이슈 · 저장소는 전부 스페이스에 속하고, 스페이스에 속한 테이블은 spaceId 를 직접 가진다. 볼 수 없는 리소스는 403 이 아니라 404 로 답한다.

외부 원본(GitHub)은 사본을 두지 않고 프록시한다. Redis 는 쓰지 않는다 — 큐도 Postgres 에 둔다.


폴더 구조

폴더설명
server/NestJS 백엔드 — REST API · Socket.IO 게이트웨이 · 계약 검증 스크립트(scripts/)
app/Flutter 앱 — 실행법은 app/README.md
deploy/배포 구성 — prod compose · nginx · 절차는 deploy/README.md
design-system/디자인 토큰(tokens.css) · 컴포넌트 · 화면 프리뷰
docs/기획 · 설계 문서 · 진행 기록

시작하기

필요한 것: Node.js 22 · Flutter 3.44.9 이상 · WSL2(Ubuntu) 또는 Docker

서버

git clone https://github.com/dbsrjs/Nexus.git && cd Nexus

npm --prefix server install
npm run env:setup                          # server/.env 생성 · 시크릿 자동 채움

npm run db:setup                           # (1회) WSL 안에 Postgres + pgvector
npm run db:up                              # Docker 라면 위 둘 대신 npm run db:up:docker

npm --prefix server run prisma:generate
npm --prefix server run prisma:deploy
npm run db:seed

npm run server:dev                         # http://localhost:3000/api

env:setup 은 여러 번 돌려도 안전하다. 만들어 낼 수 없는 값(GITHUB_CLIENT_ID · GITHUB_CLIENT_SECRET · PUBLIC_BASE_URL)은 끝에 목록으로 알려 준다 — GitHub 연동을 쓸 때만 필요하다. AI · 인덱싱은 .env 의 LLM_PROVIDER · EMBEDDING_PROVIDER 를 채워야 켜진다.

Windows 에서는 DATABASE_URL 에 localhost 대신 127.0.0.1 을 쓴다(WSL 포워딩이 IPv4 만 동작한다). 서버가 listen EACCES ...:3000 으로 죽으면 Windows 가 그 포트를 예약한 것이다 — 처방은 CLAUDE.md §2.

앱

cd app
flutter pub get

flutter run -d windows --dart-define=API_BASE=http://127.0.0.1:3000
flutter run -d chrome  --web-port=5173     # 서버 CORS 가 5173 만 허용한다

Android 에뮬레이터는 --dart-define=API_BASE=http://10.0.2.2:3000 을 넘긴다(에뮬레이터에게 127.0.0.1 은 자기 자신이다). Windows 데스크톱 빌드에는 개발자 모드가 켜져 있어야 한다.


검증

명령내용
명령내용규모 (2026-10-09)
---------
npm run server:test · server:lint서버 단위 테스트(Jest) · ESLint — 순수 로직 · 가드 · 권한 규칙535개
npm run check:*실서버 · 실DB · 실소켓 계약 검증 — 실시간 · 리액션 · 스레드 · 첨부 · 이슈 · GitHub 연동 · 인덱싱 · AI · 설정 · 멤버 · 권한 · DM · 프레즌스 · 알림 · 테넌트 격리(check:tenancy — 스페이스 경로 전부를 남의 id 로 친다). GitHub 은 스스로 띄우는 가짜 서버로 대신한다20종 1,123개
npm run check:migrations · check:sql-time마이그레이션 · raw SQL 정적 검사 (DB 불필요)
cd app && flutter analyze && flutter test앱 정적 분석 · 단위 · 위젯 테스트591개
npm run app:flow앱 통합 테스트 — Windows 앱을 실서버에 붙여 로그인부터 전송 · 실시간 · 설정 · 멤버 · DM · 알림함 · AI 까지 돈다약 30초
npm run app:flow:headless같은 흐름을 창 없이(flutter_tester) 돈다 — CI 가 이것을 돈다약 15초

CI(.github/workflows/ci.yml)가 main 과 feat/** 의 push 마다 위 전부를 돈다 — 앱 통합 테스트는 창 없는 쪽(app:flow:headless)으로, 첨부는 S3 경로(SeaweedFS)로도 한 번 더. Windows 창으로 보는 app:flow 는 화면 모습을 바꿨을 때 사람이 돌린다.


진행 상황

1~19단계와 «마지막»(출시 준비)의 네 갈래가 끝났다(2026-10-09). 위 기능표가 전부 동작하고, 배포 구성(deploy/) · 테넌트 격리 통합 검증 · 딥링크 · 데스크톱 · 웹 알림 + Windows 트레이까지 들어갔다.

남은 것내용
실제 VM 배포구성과 절차(deploy/README.md)는 있다. VM · R2 · Cloudflare Tunnel 을 정하고 올리는 일
OS 수준 링크 연결Android App Links · Windows 프로토콜 등록 — 공개 도메인이 정해진 뒤
20 GitLab 연동provider 추상화 뒤에 GitLab. 배포 뒤로 미뤄도 되는 유일한 단계

모바일 푸시(FCM)는 범위에서 뺐다(2026-10-09). 단계마다의 결정과 확인 내역은 진행 기록 에 있다.

로드맵목표
Phase 0나 혼자 쓰는 개발 허브 — 프로젝트를 채널로 나누고 할 일 · 저장소를 붙인다
Phase 12~10인 소규모 팀 — 초대 · 온보딩 · 알림
Phase 2공개 서비스 — 테넌트 격리 · 스토리지 쿼터 · 과금

문서

문서내용
코드 둘러보기처음 열었을 때 여기부터. 돌려 보기 · 구조 · 한 줄기 따라가기
제품 기획방향 · 타겟 · 기능 범위 · 로드맵
백엔드 설계멀티테넌시 · 데이터 모델 · API 계약 · 실시간 · 인증
앱 설계Flutter 스택 · 화면 · 상태 관리 · 오프라인 전략
인프라 설계배포 구성 · 공개 저장소 보안 체크리스트
디자인 시스템색 · 타이포 · 간격 · 컴포넌트
전환 계획작업 목록과 진행 상황
진행 기록단계마다 갈린 결정 · 확인한 것 · 확인하지 못한 것
기술 스택 가이드스택별 학습 순서 · 코드 읽기 시작점
서버 README서버 셋업 · 규약 · Ollama · 실제 GitHub 웹훅 붙이는 법
배포 READMEVM · R2 · Cloudflare Tunnel 로 올리는 절차
단계별 설계 스펙단계마다 정한 것 · 범위에서 뺀 것과 그 이유
Source 3 files
hooks/register.tsx 204 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CiSnapshot, GitSnapshot } from '../types'
5import { ago, ciLabel, parseRuns, parseStatus } from './parse'
6
7const git = atom({ plugin: 'git-band', key: 'git' } as const, null)
8const ci = atom({ plugin: 'git-band', key: 'ci' } as const, null)
9
10// git 은 싸서 자주, CI(gh → GitHub API)는 비싸서 드물게 본다.
11// 실행 중인 CI 만은 결과가 곧 바뀌므로 git 주기에 맞춰 따라간다.
12const TICK_MS = 15_000
13const CI_EVERY_TICKS = 6
14
15// 진행 중인 작업 표시. 파일 이름은 git 이 쓰는 그대로다.
16const OPERATIONS: readonly [string, string][] = [
17  ['rebase-merge', '리베이스 중'],
18  ['rebase-apply', '리베이스 중'],
19  ['MERGE_HEAD', '병합 중'],
20  ['CHERRY_PICK_HEAD', '체리픽 중'],
21  ['REVERT_HEAD', '되돌리기 중'],
22  ['BISECT_LOG', 'bisect 중'],
23]
24
25// 모듈 변수는 리로드 때 초기화돼도 괜찮은 것만 둔다(겹침 방지 · 주기 계산).
26// $ 를 받는 함수는 검증기가 따라갈 수 있게 최상위 함수 선언으로 둔다.
27let busy = false
28let tick = 0
29let lastCiKey = ''
30
31async function run($: EngineInterface, argv: string[], cwd?: string) {
32  try {
33    return await $.process.run(argv, { cwd, timeoutMs: 20_000 })
34  } catch {
35    // 명령이 없거나(ENOENT) 시간 초과 — 호출부가 「모름」으로 접는다.
36    return null
37  }
38}
39
40async function readGit($: EngineInterface): Promise<GitSnapshot | null> {
41  const top = await run($, ['git', 'rev-parse', '--show-toplevel', '--absolute-git-dir'])
42  if (!top || top.exitCode !== 0) return null
43  const [root, gitDir] = top.stdout.trim().split(/\r?\n/)
44  if (!root || !gitDir) return null
45
46  const [status, log] = await Promise.all([
47    run($, ['git', 'status', '--porcelain=v2', '--branch'], root),
48    // 날짜는 %cr(로케일 영어) 대신 ISO 로 받아 한국어로 직접 쓴다.
49    run($, ['git', 'log', '-1', '--format=%h%x1f%s%x1f%cI'], root),
50  ])
51  if (!status || status.exitCode !== 0) return null
52
53  let operation: string | null = null
54  for (const [file, label] of OPERATIONS) {
55    try {
56      await $.fs.stat(`${gitDir}/${file}`)
57      operation = label
58      break
59    } catch {
60      // 없으면 그 작업이 아니다.
61    }
62  }
63
64  let lastCommit: GitSnapshot['lastCommit'] = null
65  if (log && log.exitCode === 0 && log.stdout.trim()) {
66    const [hash, subject, iso] = log.stdout.trim().split('\x1f')
67    lastCommit = { hash: hash ?? '', subject: subject ?? '', age: iso ?? '' }
68  }
69
70  const name = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? root
71  return { project: name, operation, lastCommit, ...parseStatus(status.stdout) }
72}
73
74async function readCi($: EngineInterface, snap: GitSnapshot): Promise<CiSnapshot> {
75  if (snap.isDetached) return { kind: 'unknown', reason: '분리된 HEAD' }
76  const r = await run($, [
77    'gh', 'run', 'list',
78    '--branch', snap.branch,
79    '--limit', '1',
80    '--json', 'status,conclusion,workflowName,createdAt,headSha',
81  ])
82  if (!r) return { kind: 'unknown', reason: 'gh 없음' }
83  if (r.exitCode !== 0) {
84    const msg = r.stderr.toLowerCase()
85    if (msg.includes('auth') || msg.includes('login')) return { kind: 'unknown', reason: 'gh 로그인 필요' }
86    if (msg.includes('could not resolve') || msg.includes('no git remote')) return { kind: 'unknown', reason: 'GitHub 원격 없음' }
87    return { kind: 'unknown', reason: 'gh 실패' }
88  }
89  return parseRuns(r.stdout)
90}
91
92async function refresh($: EngineInterface, forceCi: boolean) {
93  if (busy) return
94  busy = true
95  try {
96    const snap = await readGit($)
97    await update($, git, () => snap)
98    if (!snap) return
99
100    const prev = await read($, ci)
101    const ciKey = `${snap.project}|${snap.branch}|${snap.oid}`
102    const isRunning = prev?.kind === 'run' && prev.status !== 'completed'
103    // 브랜치나 HEAD 가 바뀌면(커밋 · 체크아웃 · push 후) 곧바로 다시 본다.
104    if (forceCi || isRunning || ciKey !== lastCiKey || tick % CI_EVERY_TICKS === 0) {
105      lastCiKey = ciKey
106      const next = await readCi($, snap)
107      await update($, ci, () => next)
108    }
109  } finally {
110    busy = false
111  }
112}
113
114export const register: Register = on => {
115  on('session.start', async ($, e, next) => {
116    const r = await next(e)
117    void refresh($, true)
118    $.clock.every(TICK_MS, () => {
119      tick++
120      void refresh($, false)
121    })
122    await $.command.register({
123      name: 'git-band',
124      description: 'git 상태 띠를 지금 새로 고친다(CI 포함)',
125    })
126    return r
127  })
128
129  on('command.run', { command: 'git-band' }, async $ => {
130    await refresh($, true)
131    return { text: 'git 상태를 새로 고쳤습니다.' }
132  })
133
134  // 턴이 끝나면 Claude 가 커밋 · 체크아웃 · push 했을 수 있으니 바로 반영한다.
135  on('turn.complete', async ($, e, next) => {
136    const r = await next(e)
137    void refresh($, false)
138    return r
139  })
140
141  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
142    if (e.props.hasSurvey) return next(e)
143    const snap = await read($, git)
144    if (!snap) return next(e)
145    const c = await read($, ci)
146    const { Box, Text } = $.ui.resolve(e)
147    const now = await $.clock.now()
148
149    const sync =
150      snap.upstream === null
151        ? '원격 추적 없음'
152        : snap.ahead === 0 && snap.behind === 0
153          ? '동기화됨'
154          : `↑${snap.ahead} ↓${snap.behind}`
155    const changes = [
156      snap.conflicted ? `충돌 ${snap.conflicted}` : '',
157      snap.staged ? `스테이징 ${snap.staged}` : '',
158      snap.unstaged ? `수정 ${snap.unstaged}` : '',
159      snap.untracked ? `새 파일 ${snap.untracked}` : '',
160    ].filter(Boolean)
161    const changeText = changes.length ? changes.join(' · ') : '깨끗함'
162    const changeColor = snap.conflicted ? 'error' : changes.length ? 'warning' : 'success'
163
164    let ciText: { text: string; color: string } | null = null
165    let ciMeta = ''
166    if (c) {
167      ciText = ciLabel(c)
168      if (c.kind === 'run') {
169        const when = ago(c.createdAt, now)
170        // 최신 실행이 지금 HEAD 의 것이 아니면 「아직 push 안 함 / CI 안 돎」을 알린다.
171        const stale = c.headSha && snap.oid && c.headSha !== snap.oid ? ' · 이전 커밋 기준' : ''
172        ciMeta = ` ${c.workflow}${when ? ` · ${when}` : ''}${stale}`
173      }
174    }
175
176    return (
177      <Box flexDirection="column" width={e.props.bodyColumns}>
178        <Box flexDirection="row">
179          <Text wrap="truncate-end">
180            <Text bold color="claude">{snap.project}</Text>
181            <Text dimColor> ⎇ </Text>
182            <Text color="suggestion">{snap.branch}</Text>
183            {snap.isDetached ? <Text color="warning"> (분리된 HEAD)</Text> : null}
184            {snap.operation ? <Text color="error"> [{snap.operation}]</Text> : null}
185            <Text dimColor> │ </Text>
186            <Text>{sync}</Text>
187            <Text dimColor> │ </Text>
188            <Text color={changeColor}>{changeText}</Text>
189            {ciText ? <Text dimColor> │ </Text> : null}
190            {ciText ? <Text color={ciText.color}>{ciText.text}</Text> : null}
191            {ciMeta ? <Text dimColor>{ciMeta}</Text> : null}
192          </Text>
193        </Box>
194        {snap.lastCommit ? (
195          <Text dimColor wrap="truncate-end">
196            {snap.lastCommit.hash} {snap.lastCommit.subject}
197            {ago(snap.lastCommit.age, now) ? ` · ${ago(snap.lastCommit.age, now)}` : ''}
198          </Text>
199        ) : null}
200      </Box>
201    )
202  })
203}
204
hooks/parse.ts 104 lines
1import type { CiSnapshot, GitSnapshot } from '../types'
2
3// git status --porcelain=v2 --branch 를 파싱한다. 사람이 읽는 형식(git status)은
4// 로케일 · 버전마다 문구가 바뀌어 기계용 v2 형식을 쓴다.
5export function parseStatus(
6  text: string,
7): Omit<GitSnapshot, 'project' | 'operation' | 'lastCommit'> {
8  let oid = ''
9  let head = ''
10  let upstream: string | null = null
11  let ahead = 0
12  let behind = 0
13  let staged = 0
14  let unstaged = 0
15  let untracked = 0
16  let conflicted = 0
17
18  for (const line of text.split(/\r?\n/)) {
19    if (line.startsWith('# branch.oid ')) oid = line.slice(13)
20    else if (line.startsWith('# branch.head ')) head = line.slice(14)
21    else if (line.startsWith('# branch.upstream ')) upstream = line.slice(18)
22    else if (line.startsWith('# branch.ab ')) {
23      const m = /\+(\d+) -(\d+)/.exec(line)
24      if (m) {
25        ahead = Number(m[1])
26        behind = Number(m[2])
27      }
28    } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
29      // XY: X 는 인덱스(스테이징), Y 는 작업 트리. '.' 은 변경 없음.
30      const xy = line.slice(2, 4)
31      if (xy[0] !== '.') staged++
32      if (xy[1] !== '.') unstaged++
33    } else if (line.startsWith('u ')) conflicted++
34    else if (line.startsWith('? ')) untracked++
35  }
36
37  const isDetached = head === '(detached)'
38  return {
39    oid,
40    branch: isDetached ? oid.slice(0, 7) : head,
41    isDetached,
42    upstream,
43    ahead,
44    behind,
45    staged,
46    unstaged,
47    untracked,
48    conflicted,
49  }
50}
51
52// gh run list --json 결과 중 첫 줄. 실패를 'unknown' 으로 접어 띠가 이유를 보이게 한다.
53export function parseRuns(stdout: string): CiSnapshot {
54  let runs: unknown
55  try {
56    runs = JSON.parse(stdout)
57  } catch {
58    return { kind: 'unknown', reason: 'gh 응답 해석 실패' }
59  }
60  if (!Array.isArray(runs) || runs.length === 0) return { kind: 'none' }
61  const r = runs[0] as Record<string, unknown>
62  return {
63    kind: 'run',
64    workflow: String(r.workflowName ?? ''),
65    status: String(r.status ?? ''),
66    conclusion: String(r.conclusion ?? ''),
67    createdAt: String(r.createdAt ?? ''),
68    headSha: String(r.headSha ?? ''),
69  }
70}
71
72// 「3분 전」 같은 상대 시각. 모르는 값이면 빈 문자열(화면이 말하지 않는다).
73export function ago(iso: string, nowMs: number): string {
74  const t = Date.parse(iso)
75  if (Number.isNaN(t)) return ''
76  const s = Math.max(0, Math.round((nowMs - t) / 1000))
77  if (s < 60) return '방금'
78  if (s < 3600) return `${Math.floor(s / 60)}분 전`
79  if (s < 86400) return `${Math.floor(s / 3600)}시간 전`
80  return `${Math.floor(s / 86400)}일 전`
81}
82
83export function ciLabel(ci: CiSnapshot): { text: string; color: string } {
84  if (ci.kind === 'unknown') return { text: `CI ? (${ci.reason})`, color: 'inactive' }
85  if (ci.kind === 'none') return { text: 'CI 실행 없음', color: 'inactive' }
86  if (ci.status !== 'completed') {
87    return { text: ci.status === 'queued' ? 'CI ⏳ 대기' : 'CI ⟳ 실행 중', color: 'warning' }
88  }
89  switch (ci.conclusion) {
90    case 'success':
91      return { text: 'CI ✓ 통과', color: 'success' }
92    case 'failure':
93    case 'timed_out':
94    case 'startup_failure':
95      return { text: 'CI ✗ 실패', color: 'error' }
96    case 'cancelled':
97      return { text: 'CI ⊘ 취소', color: 'inactive' }
98    case 'skipped':
99      return { text: 'CI – 건너뜀', color: 'inactive' }
100    default:
101      return { text: `CI ${ci.conclusion}`, color: 'inactive' }
102  }
103}
104
types/index.d.ts 36 lines
1// 띠가 그리는 git 상태. 저장소가 아니면 null.
2export type GitSnapshot = {
3  project: string
4  oid: string
5  branch: string
6  isDetached: boolean
7  upstream: string | null
8  ahead: number
9  behind: number
10  staged: number
11  unstaged: number
12  untracked: number
13  conflicted: number
14  operation: string | null
15  lastCommit: { hash: string; subject: string; age: string } | null
16}
17
18// gh 로 읽은 최신 워크플로 실행. gh 가 없거나 인증이 안 되면 kind 가 'unknown'.
19export type CiSnapshot =
20  | { kind: 'unknown'; reason: string }
21  | { kind: 'none' }
22  | {
23      kind: 'run'
24      workflow: string
25      status: string
26      conclusion: string
27      createdAt: string
28      headSha: string
29    }
30
31declare module 'claude-code' {
32  interface PluginState {
33    'git-band': { git: GitSnapshot | null; ci: CiSnapshot | null }
34  }
35}
36