SLOPSHOPPER

orch-panel

오케스트레이션 현황 패널: orch-dash 가 모은 state.json 과 orch stage 기록만 읽어 결정 대기·사람만 항목, 작업 진행 표, 세션 관계와 공유 자원 보유자, Orca automation 실행 현황을 상태줄과 접는 패널로 보여 준다

newpanecommandtoaststatusnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · orch-panel
│ ┃ orch ✕ › fix the failing auth test and add an audit log call │ ┃ ■ orch-dash 꺼짐 │ ┃ orch-dash ensure 로 켜면 결정 대기·사람만 ⏺ 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 │ │ › /orch-panel │ ⎿ orch-panel: orch 패널을 열었다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · orch
■ orch-dash 꺼짐 orch-dash ensure 로 켜면 결정 대기·사람만 항목이 여기 나온다
README

agent-skills

Claude Code 개인 스킬 저장소. 티켓 하나를 끝까지 끌고 가는 흐름과, Orca 워커 여러 개로 프로젝트 하나를 끝내는 흐름을 스킬로 만든다. 일부 스킬은 Lauren Tan의 pstack(MIT)에서 가져왔다.

한눈에 보기: 스킬 카탈로그(스킬별 언제·무엇, docs/skills.md에서 생성), 사용 안내서(흐름과 orch 착지 과정 그림).

두 페이지는 한국어·English·日本語를 오른쪽 위에서 고른다. 링크 끝에 #en이나 #ja를 붙이면 그 언어로 열린다. 카탈로그는 python3 scripts/catalog/build.py <out.html>이 docs/skills.md와 번역본 docs/skills.{en,ja}.md로 만든다. 번역본 첫 줄의 translated-from 커밋 뒤에 docs/skills.md가 바뀌었으면 경고하고, 그 언어 하단에 원문보다 오래됐다고 표시한다. 안내서는 손으로 쓴 한 장짜리 scripts/catalog/guide.html이라, 스킬이 늘거나 흐름이 바뀌면 세 언어를 함께 고친다.

전제는 Orca(멀티 에이전트 IDE, orca CLI), GitHub, 티켓 트래커(Linear, Jira 어댑터 준비)다. 노트는 Obsidian vault 또는 일반 Markdown 폴더를 쓴다.

선택 도구: 교차 검토는 Codex 플러그인의 companion 스크립트, 브라우저 검증은 브라우저 자동화 도구를 쓴다. 없으면 그 단계만 다른 도구로 바꾼다.

설치

이 저장소를 고쳐 가며 쓰는 PC는 체크아웃을 Claude Code가 직접 읽게 한다. 스킬을 고치면 떠 있는 세션에도 reload 한 번으로 반영되고, 원격 변경은 15분마다 자동으로 받는다. 설계와 실측 근거는 docs/platform.md.

체크아웃은 어느 경로에 두어도 된다. bootstrap.py는 자기 파일 위치로 체크아웃 경로를 정한다. 아래 <체크아웃>은 clone한 경로로 바꾼다(예시: ~/src/agent-skills).

git clone https://github.com/JeongJaeSoon/agent-skills <체크아웃>
python3 <체크아웃>/scripts/bootstrap.py           # 바뀔 내용 보기
python3 <체크아웃>/scripts/bootstrap.py --write   # 적용

bootstrap.py --write가 하는 일:

  • ~/.claude/settings.json을 백업하고, env 블록의 CLAUDE_CODE_PLUGIN_DIRS에 체크아웃 경로를 넣는다. 마켓플레이스로 설치한 agent-skills@jeongjaesoon이 켜져 있으면 끈다. 둘 다 켜면 hook이 두 번 돈다.
  • macOS면 launchd 작업(io.github.jeongjaesoon.agent-skills-sync)을 설치해 15분마다와 로그인 때 skills-sync sync를 돌린다. Linux면 crontab 한 줄을 출력한다.
  • 이미 된 단계는 건너뛰므로 다시 돌려도 된다. 새 설정은 새 세션부터 적용된다. 떠 있는 세션은 재시작한다. 거기서 /reload-plugins를 치면 마켓플레이스 사본만 빠지고 체크아웃은 붙지 않아, 재시작할 때까지 agent-skills가 통째로 없다. 체크아웃 폴더에서 claude를 처음 띄우면 폴더 신뢰 창이 한 번 뜬다.

고치지 않고 쓰기만 하는 PC는 마켓플레이스로 설치해도 된다. 설치본은 커밋 단위 복사본이라 갱신은 claude plugin marketplace update jeongjaesoon 뒤 claude plugin update agent-skills@jeongjaesoon이다.

claude plugin marketplace add JeongJaeSoon/agent-skills
claude plugin install agent-skills@jeongjaesoon
  • 스킬 이름은 /agent-skills:<이름>. 다른 플러그인과 겹치지 않으면 /<이름>도 된다.
  • 권한: 플러그인은 권한 규칙을 설정으로 실을 수 없어 hooks/guard.py가 대신 결정한다. 이 플러그인의 스킬, orca orchestration 명령(reset·worker-abandon·gate-resolve 제외), orch 명령(heavy·set·init·backfill 제외)을 허용하고, 다른 터미널의 Orca 메일함 읽기를 거부한다. 나머지는 평소 권한 흐름을 탄다. 자세한 규칙은 스킬 카탈로그.
  • 이름이 바뀐 스킬(ship-pr → deliver-ticket, dispatch-work → dispatch-card, use-obsidian → use-notes)은 legacy/의 안내용 별칭으로 남아 있다.

예전 scripts/install.py(symlink와 settings 병합)로 설치했다면, 그 설치로 시작한 프로그램이 모두 끝난 뒤 python3 scripts/migrate.py(바뀔 내용 보기), --write(symlink·옛 규칙·옛 hook 제거 뒤 bootstrap.py --write) 순서로 옮긴다.

스킬 고치기

  • 고치는 곳은 worktree 브랜치다. 메인 체크아웃은 세션들이 직접 읽으므로 fast-forward로만 바꾼다. 메인 체크아웃에 커밋 안 한 변경이 있으면 자동 동기화가 멈춘다.
  • worktree의 스킬로 세션 하나를 띄워 보려면 claude --settings '{"env":{"CLAUDE_CODE_PLUGIN_DIRS":"<worktree 절대 경로>"}}'. --plugin-dir을 더하면 메인 체크아웃과 두 벌이 함께 올라온다.
  • 메인 체크아웃에 들어간 뒤에는 skills-sync sync(push까지)와 skills-sync broadcast를 돌린다. 브로드캐스트는 SKILL.md가 바뀌었으면 /reload-skills, hook 연결(hooks/hooks.json)이나 manifest가 바뀌었으면 /reload-plugins를, 빈 입력칸에서 쉬고 있는 세션에만 보낸다. 권한 창이나 질문 창이 뜬 세션에 Enter가 가면 그 창에 답해 버리기 때문이다. 못 보낸 세션은 남겨 두었다가 다음 동기화 때 다시 시도한다.
  • description이나 트리거 문구를 바꿨으면 고치기 전 체크아웃과 나란히 scripts/trigger-probe.sh를 돌려 발동이 달라졌는지 본다. 임시 저장소에서 헤드리스 세션을 띄우고 쓰기·셸·MCP 호출은 모두 거부하므로 다른 곳을 건드리지 않는다. 한 번에 0.2달러 안팎이 든다.
bash scripts/trigger-probe.sh <체크아웃> "남은 작업들 병렬로 진행해줘" 3 [복사할 저장소]

트래커와 노트 저장소는 설정 없이도 동작한다. 설정 파일이 없으면 트래커는 Linear, 노트는 Obsidian vault Private이고, 노트는 연결된 Obsidian MCP 도구로, 트래커는 연결된 Linear MCP 도구(없으면 orca linear CLI)로 읽고 쓴다. Jira나 일반 Markdown 폴더, 다른 vault를 쓸 때만 ~/.claude/agent-skills.json을 둔다(선택). 형식은 use-tracker, use-notes 스킬에 있다.

recall이 지난 세션을 빨리 찾게 하려면 Orca 앱에서 Settings → Agent Session Search → Search inside sessions의 이 컴퓨터(Local Mac) 스위치를 켠다. CLI로는 켤 수 없고, 켜기 전에는 recall이 ~/.claude/projects의 transcript를 grep한다. 켜졌는지는 orca search --index-status의 enabled로 확인한다.

스킬

스킬별 호출 시점, 실제 내용, 동봉 파일, orch 명령, hook, pstack과의 차이는 스킬 카탈로그에 있다.

티켓 하나

스킬언제
write-ticket티켓을 쓸 때. 후속 티켓 형식, 템플릿
deliver-ticket첫 수정부터 완료까지: 계획, 테스트, Codex 교차 검토, GitHub stack, E2E, 머지, 완료 기준
handoff-ticket끝난 뒤 다음 티켓을 새 Orca 카드로
dispatch-card이 세션은 계속하면서 다른 저장소나 곁가지를 카드로
end-session세션·카드·worktree 정리. Orca 워커와 브리프로 띄운 카드는 일을 마치면 스스로 닫는다

프로젝트 하나 (Orca 여러 워커)

스킬언제
orchestrate코디네이터가 여러 티켓을 병렬로 착지시킬 때. 착지 순서, 독점 레인, main 가디언, QA 리드, 대시보드
measure-delivery프로젝트가 실제로 어땠는지: 후속 티켓 증가, 재작업, 토큰

머신 하나

스킬언제
reap-resources죽은 세션이 남긴 Codex broker 트리, 고아 테스트 프로세스, Docker 잔여물, 머지된 브랜치를 점검하고 목록에 올린 것만 정리할 때. 에이전트가 만든 끝난 리소스와 다운로드·바탕화면의 7일(스크린샷은 1일) 지난 항목은 3시간 주기 automation이 휴지통으로 정리한다
secure-fill브라우저 인증 화면에 개발용 토큰 같은 비밀값을 넣을 때. 소유자가 매번 Touch ID로 승인하고(항목별로 승인 없이 채우게 둘 수 있다), 값은 에이전트를 거치지 않고 Keychain에서 페이지로 간다
tune-automodeauto mode 분류기 거부 하나를 그 행동 부류만 덮는 가장 작은 autoMode 규칙으로 바꿀 때. diff와 적용 스크립트를 만들고, 적용은 사용자가 ! python3 <경로> --apply <spec 해시>로 한다

채팅: watch-mentions(공개 채널의 멘션·이름 언급·참여 스레드 새 답글을 분류해 코디네이터에게 한 통으로 보내고, 작업 의뢰는 기표 초안으로).

어댑터: use-tracker(Linear, Jira), use-notes(Obsidian, Markdown).

글쓰기: write-plainly(한국어·영어 문체), brief-status(현황 보고: 완료·진행·결정·사람만, Telegram이면 markdown 없는 짧은 줄, Claude Code에서는 /agent-skills:brief로도), prune-comments(diff의 주석 정리), write-skill(스킬을 쓰거나 고친 뒤의 점검: 겹침 확인, prompt audit, 사내 이름 grep, 테스트, reload).

pstack: architect, arena, blast-radius, create-verification-skill, figure-it-out, how, interrogate, maintain-verification-skill, principles, recall, reflect, show-me-your-work, swarm, tdd, teach, why. 원본 이름을 유지해 upstream을 따라간다.

python3 scripts/pstack-sync.py            # upstream 변경 확인 (쓰지 않음)
python3 scripts/pstack-sync.py --write    # 충돌이 없을 때만 반영하고 pin 갱신

출처와 수정 내용은 vendor/pstack/NOTICE.md, 고정 커밋은 vendor/pstack/manifest.json.

orchestrate 한눈에

  • 워커가 자기 PR을 orch land로 착지시킨다. 준비된 PR은 base보다 뒤처져 있어도 병렬로 머지되고, migration·CI·Dockerfile·compose 같은 공유 파일은 독점 레인에서 base당 하나씩 최신 base 위에서 머지된다.
  • 의존 관계는 Orca task deps가 정본이다. 순서대로 들어가야 하는 체인은 GitHub stack으로 한 번에 머지한다.
  • main이 red가 되면 main 가디언이 flake 여부부터 보고 hotfix나 revert를 고른다. QA 리드가 티켓 검증, 주기 E2E, 설계·코드 정합성 감사를 맡는다.
  • orch-dash serve: 목표, 완료 조건, 착지 순서, 의존 그래프, 워커, 토큰을 보여주는 대시보드(라이트·다크, 창 크기에 맞춤). 모델 호출 없이 JSON으로 갱신한다.

mod: orch-panel

mods/orch-panel/은 Claude Code 터미널 안에서 오케스트레이션 현황을 보는 mod(function hook 플러그인)다. 대시보드가 이력과 숫자를 보여 준다면, 이 mod는 지금 이 세션에서 볼 것만 보여 준다.

  • 상태줄: 결정 2 · 사람만 1 · 자동화 ▲1. 0인 항목은 빼고, 모두 0이면 지운다.
  • /orch-panel: 결정 대기와 사람만 할 수 있는 일(권한·로그인·명령 실행)을 접고 펴는 패널. 결정은 답하는 법(d7: CSV)을 같이 보여 준다. 코디네이터 세션에서는 새 항목이 생길 때 토스트를 띄우고 패널을 연다.
  • 작업 진행: orch stage로 기록한 작업별 단계 표(stages.json)를 ▰▰▰▱▱ 3/5 진행 바와 머지·dev·prod 칸의 ✓ ✗ △ ◐ –(기호마다 색)로 그린다. 머지 칸과 PR 옆 ✗CI·◐CI·✎·✓는 state.json의 PR 목록에서 읽는다. 행을 누르면 각 칸의 근거가 펼쳐지고, 아래에 다음 줄과 표에 없는 열린 PR 수가 붙는다.
  • 관계(접힌 채 시작): 세션 트리를 대시보드와 같은 phase 색과 점(◉ 작업 중 · ◍ 대기 · ○ idle · · open)으로 그리고, offline은 + offline N으로 접는다. 아래 공유 자원에 착지 lane과 orch hold로 잡은 자원(브라우저 등)의 보유자와 시간을 보인다.
  • 자동화(접힌 채 시작): Orca automation마다 주기·다음 실행·마지막 실행과 최근 6회를 색 블록(■ 완료 초록 · ■ 실패 빨강 · ◧ 실행 중 · □ 건너뜀)으로 보인다. 마지막 실행이 실패했거나 예정 시각을 10분 넘기면 ▲로 표시한다. Orca automation만 보이며, 세션 안의 CronCreate와 launchd 작업은 수집하지 않는다.
  • 읽는 것은 orch-dash가 모은 state.json과 stages.json, 같은 디렉터리의 두 파일이다($ORCH_FLEET_STATE, 기본 ~/.local/state/agent-skills/dashboard). 5초마다 stat하고 둘 중 하나가 바뀌었을 때만 읽는다. gh·orca나 모델은 부르지 않는다. 네트워크는 패널의 ↻이 대시보드에 즉시 수집을 한 번 요청할 때뿐이다.

체크아웃에서 쓰려면 CLAUDE_CODE_PLUGIN_DIRS에 <체크아웃>/mods/orch-panel을 더한다. 마켓플레이스로는 claude plugin install orch-panel@jeongjaesoon. 실제 fleet 없이 보려면:

python3 mods/orch-panel/scripts/demo_state.py /tmp/orch-demo
ORCH_FLEET_STATE=/tmp/orch-demo claude --plugin-dir mods/orch-panel

테스트는 claude plugin test mods/orch-panel.

테스트

for t in skills/orchestrate/scripts/test_*.py skills/use-tracker/scripts/test_tracker.py skills/reap-resources/scripts/test_reap.py skills/watch-mentions/scripts/test_mentions.py skills/secure-fill/scripts/test_secure_fill.py skills/tune-automode/scripts/test_automode_rule.py hooks/test_*.py scripts/test_sync.py; do python3 "$t"; done
bash scripts/pstack-sync-test.sh
claude plugin test mods/orch-panel

라이선스

MIT. vendor/pstack과 거기서 가져온 스킬은 원저작자의 MIT 라이선스를 따른다(vendor/pstack/LICENSE).

Source 3 files
hooks/register.tsx 514 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  OrchPanelAutomation,
6  OrchPanelCell,
7  OrchPanelItem,
8  OrchPanelRun,
9  OrchPanelSession,
10  OrchPanelSnapshot,
11  OrchPanelStageRow,
12} from '../types'
13import {
14  DASH_URL,
15  STALE_COLLECTOR_MS,
16  STALE_ORCA_MS,
17  age,
18  ageMs,
19  automationIssue,
20  bar,
21  cadence,
22  parseAt,
23  progress,
24  sessionUrl,
25  snapshotOf,
26  stateDir,
27  statusText,
28  toastText,
29  tree,
30  until,
31} from './model'
32
33const PANE = 'orch'
34const POLL_MS = 5000
35const snapshot = atom({ plugin: 'orch-panel', key: 'snapshot' } as const, null)
36const seen = atom({ plugin: 'orch-panel', key: 'seen' } as const, null)
37const collapsed = atom({ plugin: 'orch-panel', key: 'collapsed' } as const, [])
38const expanded = atom({ plugin: 'orch-panel', key: 'expanded' } as const, [])
39
40const GLYPH: Record<string, string> = {
41  decision: '◆',
42  approval: '◆',
43  question: '?',
44  permission: '!',
45  prompt: '!',
46  login: '!',
47  run_command: '$',
48}
49
50// Stage marks as symbol plus theme color, so the table reads without color too.
51const MARK: Record<OrchPanelCell['mark'], { glyph: string; color?: string; dim?: boolean }> = {
52  ok: { glyph: '✓', color: 'success' },
53  fail: { glyph: '✗', color: 'error' },
54  partial: { glyph: '△', color: 'warning' },
55  checking: { glyph: '◐', color: 'warning' },
56  na: { glyph: '–', dim: true },
57}
58
59// The dashboard's phase colors, each with its own dot so the tree reads without color too.
60const PHASE: Record<OrchPanelSession['phase'], { glyph: string; color?: string; word: string }> = {
61  working: { glyph: '◉', color: 'permission', word: '작업 중' },
62  waiting: { glyph: '◍', color: 'warning', word: '대기' },
63  idle: { glyph: '○', color: 'success', word: 'idle' },
64  open: { glyph: '·', word: 'open' },
65  offline: { glyph: '◌', word: 'offline' },
66}
67
68// One block per automation run: filled for a run that ran, hollow for one its precheck skipped.
69const RUN: Record<string, { glyph: string; color?: string }> = {
70  completed: { glyph: '■', color: 'success' },
71  failed: { glyph: '■', color: 'error' },
72  error: { glyph: '■', color: 'error' },
73  running: { glyph: '◧', color: 'warning' },
74  dispatched: { glyph: '◧', color: 'warning' },
75  skipped: { glyph: '□' },
76  other: { glyph: '▪' },
77}
78
79// The name column is fixed so the columns after it line up; a truncating flex box drifted past its width live.
80const NAME_WIDTH = 24
81const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
82
83// Module state: a hot reload starts it over, and session.start fires again to refill it.
84const live = {
85  dir: '',
86  cwd: '',
87  mtimes: '',
88  parsed: null as OrchPanelSnapshot | null,
89  shownStatus: undefined as string | undefined,
90}
91
92async function poll($: EngineInterface) {
93  const now = await $.clock.now()
94  const [stat, stagesStat] = await Promise.all(
95    ['state.json', 'stages.json'].map(f => $.fs.stat(`${live.dir}/${f}`).catch(() => undefined)),
96  )
97  const mtimes = `${stat?.mtimeMs}:${stagesStat?.mtimeMs}`
98  if (!stat) {
99    live.mtimes = ''
100    live.parsed = { status: 'missing', checkedAt: now }
101  } else if (mtimes !== live.mtimes || !live.parsed) {
102    live.mtimes = mtimes
103    const [text, stagesText] = await Promise.all([
104      $.fs.read(`${live.dir}/state.json`).catch(() => ''),
105      stagesStat ? $.fs.read(`${live.dir}/stages.json`).catch(() => null) : null,
106    ])
107    live.parsed = snapshotOf(String(text), now, live.cwd, stagesText === null ? null : String(stagesText))
108    await announce($, live.parsed)
109  }
110  // checkedAt moves on every poll so an open pane's ages keep counting while the file is unchanged.
111  const parsed = live.parsed
112  await update($, snapshot, () => ({ ...parsed, checkedAt: now }))
113  const text = statusText(parsed)
114  if (text !== live.shownStatus) {
115    live.shownStatus = text
116    $.ui.status(text)
117  }
118}
119
120async function announce($: EngineInterface, next: OrchPanelSnapshot) {
121  if (next.status !== 'ok') return
122  const before = await read($, seen)
123  await update($, seen, () => next.items.map(i => i.key))
124  // The first read of a session only seeds: an existing backlog must not toast on every start or reload.
125  if (before === null || !next.isCoordinator) return
126  const fresh = next.items.filter(i => !before.includes(i.key))
127  const [first] = fresh
128  if (!first) return
129  $.ui.toast(fresh.length === 1 ? toastText(first) : `결정·사람만 새 항목 ${fresh.length}개`)
130  void $.ui.open({ id: PANE, title: 'orch' })
131}
132
133// The one network call: asking the dashboard for an immediate collection, as opening its page does.
134async function refresh($: EngineInterface) {
135  await $.http.fetch(`${DASH_URL}/api/fleet/state`).catch(() => undefined)
136  live.mtimes = ''
137  await poll($)
138}
139
140export const register: Register = on => {
141  on('session.start', async ($, e, next) => {
142    live.cwd = e.cwd
143    const [dir, home] = await Promise.all([$.env.get('ORCH_FLEET_STATE'), $.env.get('HOME')])
144    live.dir = stateDir({ ORCH_FLEET_STATE: dir, HOME: home })
145    await $.command.register({ name: 'orch-panel', description: '오케스트레이션 현황 패널을 연다 (결정 대기·사람만, 작업 진행)' })
146    // Not awaited: parsing a few hundred KB must not hold the first prompt.
147    void poll($)
148    $.clock.every(POLL_MS, () => void poll($))
149    return next(e)
150  })
151
152  on('command.run', { command: 'orch-panel' }, async $ => {
153    await $.ui.open({ id: PANE, title: 'orch' })
154    return { text: 'orch 패널을 열었다.' }
155  })
156
157  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
158    const { Box, Text, Button, Link } = $.ui.resolve(e)
159    const snap = await read($, snapshot)
160    const folded = await read($, collapsed)
161    const opened = await read($, expanded)
162    const now = snap?.checkedAt ?? (await $.clock.now())
163
164    if (!snap || snap.status === 'missing') {
165      return (
166        <Box flexDirection="column">
167          <Text color="warning">■ orch-dash 꺼짐</Text>
168          <Text dimColor>orch-dash ensure 로 켜면 결정 대기·사람만 항목이 여기 나온다</Text>
169        </Box>
170      )
171    }
172    if (snap.status === 'unreadable') {
173      return (
174        <Box flexDirection="column">
175          <Text color="error">■ state.json 을 읽지 못함</Text>
176          <Text dimColor wrap="truncate-end">{snap.error}</Text>
177        </Box>
178      )
179    }
180
181    const isStale = ageMs(snap.generatedAt, now) > STALE_COLLECTOR_MS
182    const isOrcaStale = !!snap.orca && (!snap.orca.isOk || ageMs(snap.orca.updatedAt, now) > STALE_ORCA_MS)
183    const flip = (ids: string[], id: string) => (ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
184    const toggle = (id: string) => () => update($, collapsed, ids => flip(ids, id))
185    const expand = (id: string) => () => update($, expanded, ids => flip(ids, id))
186
187    const decideItems = snap.items.filter(i => i.kind === 'decide')
188    const humanItems = snap.items.filter(i => i.kind === 'human')
189    const total = snap.items.length
190    const isOpen = total > 0 && !folded.includes('needs')
191    const oldest = snap.items[0]
192    const summary = !oldest
193      ? '— 없음'
194      : `· 가장 오래된 것 ${oldest.decision ?? GLYPH[oldest.type] ?? ''} ${age(oldest.at, now)}`
195
196    const row = (item: OrchPanelItem, n: number) => {
197      const waited = ageMs(item.at, now)
198      const ageColor = waited > 86_400_000 ? 'error' : waited > 3_600_000 ? 'warning' : undefined
199      const who = item.kind === 'human' && item.sessionName ? `${item.sessionName}: ` : ''
200      return (
201        <Box key={item.key} flexDirection="column">
202          <Box>
203            <Text dimColor>{String(n).padStart(2)} </Text>
204            <Text color={item.kind === 'decide' ? 'warning' : 'permission'} bold>
205              {item.decision ?? GLYPH[item.type] ?? '·'}{' '}
206            </Text>
207            <Box flexGrow={1} flexShrink={1}>
208              <Text wrap="truncate-end">
209                {who}
210                {item.title}
211                {item.recommend ? <Text color="success"> → 추천 {item.recommend}</Text> : null}
212              </Text>
213            </Box>
214            <Text color={ageColor} dimColor={!ageColor}>
215              {' '}
216              {age(item.at, now)}
217            </Text>
218            {item.session ? <Link href={sessionUrl(item.session)} label=" ↗" /> : null}
219          </Box>
220          {item.decision ? (
221            <Text dimColor>
222              {'     '}답: 프롬프트에 "{item.decision}: {item.recommend ?? '<답>'}"
223            </Text>
224          ) : null}
225        </Box>
226      )
227    }
228    const work = snap.work
229    const { done, total: rowCount } = progress(work)
230    const { filled, empty } = bar(done, rowCount)
231    const versions = [work.versions.prod && `prod ${work.versions.prod}`, work.versions.dev && `dev ${work.versions.dev}`]
232      .filter(Boolean)
233      .join(' · ')
234    const isWorkOpen = !folded.includes('work')
235    const mark = (cell: OrchPanelCell | null, width: number) => {
236      const m = cell ? MARK[cell.mark] : undefined
237      return (
238        <Box width={width}>
239          <Text color={m?.color} dimColor={!m || m.dim}>
240            {m?.glyph ?? '·'}
241          </Text>
242        </Box>
243      )
244    }
245    const evidence = (label: string, cell: OrchPanelCell | null) =>
246      cell && (cell.evidence || cell.by)
247        ? `${label} ${MARK[cell.mark].glyph} ${[cell.evidence, cell.by && `(${cell.by})`].filter(Boolean).join(' ')}`
248        : null
249    const stageRow = (r: OrchPanelStageRow) => {
250      const lines = [evidence('머지', r.merge), evidence('dev', r.dev), evidence('prod', r.prod)].filter(Boolean)
251      return (
252        <Box key={r.id} flexDirection="column">
253          <Box>
254            <Box flexGrow={1} flexShrink={1}>
255              <Button key={`row-${r.id}`} plain dimColor={r.isDone} onPress={expand(r.id)}>
256                {r.isChild ? '  ㄴ ' : '  '}
257                {r.title}
258                {r.isDone ? <Text dimColor> (끝남)</Text> : ''}
259              </Button>
260            </Box>
261            <Box width={20}>
262              <Text dimColor wrap="truncate-end">
263                {r.pr ?? '–'}
264              </Text>
265              {r.chip ? <Text color={r.chip.tone}> {r.chip.text}</Text> : null}
266            </Box>
267            {mark(r.merge, 5)}
268            {mark(r.dev, 4)}
269            {mark(r.prod, 4)}
270          </Box>
271          {opened.includes(r.id) && lines.length ? <Text dimColor>{'      ' + lines.join(' · ')}</Text> : null}
272        </Box>
273      )
274    }
275    const workSection = !isWorkOpen ? null : work.error ? (
276      <Text color="error">  ■ stages.json 을 읽지 못함: {work.error}</Text>
277    ) : !rowCount ? (
278      <Text dimColor>  기록된 단계 없음 — orch stage add 로 행을 만든다</Text>
279    ) : (
280      <Box flexDirection="column">
281        <Box>
282          <Box flexGrow={1}>
283            <Text dimColor>  작업</Text>
284          </Box>
285          <Box width={20}>
286            <Text dimColor>PR</Text>
287          </Box>
288          <Box width={5}>
289            <Text dimColor>머지</Text>
290          </Box>
291          <Box width={4}>
292            <Text dimColor>dev</Text>
293          </Box>
294          <Box width={4}>
295            <Text dimColor>prod</Text>
296          </Box>
297        </Box>
298        {work.rows.map(stageRow)}
299        {work.next.map((line, i) => (
300          <Text key={`next-${i}`} dimColor wrap="truncate-end">
301            {i === 0 ? '  다음: ' : '        '}
302            {line}
303          </Text>
304        ))}
305      </Box>
306    )
307
308    const isAutoOpen = opened.includes('section:automations')
309    const autos = snap.automations
310    const troubled = autos.filter(a => automationIssue(a, now))
311    const upcoming = autos
312      .filter(a => a.isEnabled && a.nextAt)
313      .sort((a, b) => parseAt(a.nextAt) - parseAt(b.nextAt))[0]
314    const isAutoSourceBad = !!snap.automationSource && !snap.automationSource.isOk
315    const runBlock = (r: OrchPanelRun, i: number) => {
316      const b = RUN[r.status] ?? (r.status.startsWith('skipped') ? RUN.skipped : RUN.other)
317      return (
318        <Text key={`run-${i}`} color={b?.color} dimColor={!b?.color}>
319          {b?.glyph}
320        </Text>
321      )
322    }
323    const autoRow = (a: OrchPanelAutomation) => {
324      const issue = automationIssue(a, now)
325      const last = a.recent[0]
326      return (
327        <Box key={a.id} flexDirection="column">
328          <Box>
329            <Box width={2} justifyContent="flex-end">
330              <Button
331                key={`auto-${a.id}`}
332                plain
333                dimColor
334                label={opened.includes(`auto:${a.id}`) ? '▾' : '▸'}
335                onPress={expand(`auto:${a.id}`)}
336              />
337            </Box>
338            <Box width={NAME_WIDTH}>
339              <Text dimColor={!a.isEnabled}>
340                {issue ? <Text color="error"> ▲</Text> : ''} {clip(a.isEnabled ? a.name : `${a.name} (꺼짐)`, NAME_WIDTH - (issue ? 4 : 2))}
341              </Text>
342            </Box>
343            <Box width={17}>
344              <Text dimColor wrap="truncate-end">
345                {cadence(a.rrule)}
346              </Text>
347            </Box>
348            <Box width={9}>
349              <Text dimColor={!issue} color={issue ? 'warning' : undefined}>
350                {a.isEnabled ? until(a.nextAt, now) : '–'}
351              </Text>
352            </Box>
353            <Box width={8}>
354              <Text dimColor>{a.lastAt ? `${age(a.lastAt, now)} 전` : '–'}</Text>
355            </Box>
356            <Box width={7}>{[...a.recent].reverse().map(runBlock)}</Box>
357          </Box>
358          {opened.includes(`auto:${a.id}`) ? (
359            <Text dimColor wrap="truncate-end">
360              {'      '}
361              {issue ? `${issue} · ` : ''}마지막: {last ? `${last.status} ${age(last.at, now)} 전${last.summary ? ` — ${last.summary}` : ''}` : '없음'}
362            </Text>
363          ) : null}
364        </Box>
365      )
366    }
367    const autoSection = !isAutoOpen ? null : (
368      <Box flexDirection="column">
369        {isAutoSourceBad ? (
370          <Text color="warning">  ▲ Orca automation 목록을 못 읽음 ({age(snap.automationSource?.updatedAt ?? null, now)} 전 값)</Text>
371        ) : null}
372        {autos.length ? (
373          <Box>
374            <Box width={NAME_WIDTH + 2}>
375              <Text dimColor>  이름</Text>
376            </Box>
377            <Box width={17}>
378              <Text dimColor>주기</Text>
379            </Box>
380            <Box width={9}>
381              <Text dimColor>다음</Text>
382            </Box>
383            <Box width={8}>
384              <Text dimColor>마지막</Text>
385            </Box>
386            <Box width={7}>
387              <Text dimColor>최근</Text>
388            </Box>
389          </Box>
390        ) : (
391          <Text dimColor>  등록된 automation 없음</Text>
392        )}
393        {autos.map(autoRow)}
394        <Text dimColor>  Orca automation 만 표시 (세션 CronCreate·launchd 는 수집 밖)</Text>
395      </Box>
396    )
397
398    // Collapsed until asked for: the tree is the coordinator's long view, not what changes minute to minute.
399    const isRelOpen = opened.includes('section:relations')
400    const rel = tree(snap.sessions, new Set(snap.items.map(i => i.session ?? '')))
401    const working = rel.lines.filter(l => l.session.phase === 'working').length
402    const waiting = rel.lines.filter(l => l.session.phase === 'waiting').length
403    const sessionLine = ({ session: s, prefix }: { session: OrchPanelSession; prefix: string }) => {
404      const p = PHASE[s.phase]
405      return (
406        <Box key={`s-${s.id}`}>
407          <Box flexGrow={1} flexShrink={1}>
408            <Text wrap="truncate-end">
409              <Text dimColor>{'  ' + prefix}</Text>
410              <Text color={p.color} dimColor={!p.color}>
411                {p.glyph}{' '}
412              </Text>
413              {s.name}
414              {s.isMe ? <Text dimColor> (나)</Text> : ''}
415              {s.task ? <Text dimColor> {s.task}</Text> : ''}
416              {s.pr ? <Text dimColor={s.isPrMerged}> #{s.pr}{s.isPrMerged ? ' 머지됨' : ''}</Text> : ''}
417            </Text>
418          </Box>
419          <Text color={p.color} dimColor={!p.color}>
420            {' '}
421            {p.word}
422          </Text>
423          <Link href={sessionUrl(s.id)} label=" ↗" />
424        </Box>
425      )
426    }
427    const relSection = !isRelOpen ? null : (
428      <Box flexDirection="column">
429        {rel.lines.map(sessionLine)}
430        {rel.offline ? <Text dimColor>  + offline {rel.offline}</Text> : null}
431        <Text dimColor>  공유 자원</Text>
432        {snap.holds.length === 0 ? (
433          <Text dimColor>    기록 없음 — 브라우저 같은 공유 자원은 orch hold &lt;자원&gt; 으로 잡는다</Text>
434        ) : (
435          snap.holds.map(hold => (
436            <Box key={`h-${hold.kind}-${hold.resource}`}>
437              <Box width={28}>
438                <Text wrap="truncate-end">
439                  {'    '}
440                  {hold.kind === 'lane' ? `${hold.resource} 착지 lane` : hold.resource}
441                </Text>
442              </Box>
443              <Box flexGrow={1} flexShrink={1}>
444                <Text wrap="truncate-end">
445                  <Text color="warning">■ </Text>
446                  {hold.byName ?? hold.by ?? '?'}
447                  {hold.note ? <Text dimColor> {hold.note}</Text> : ''}
448                </Text>
449              </Box>
450              <Text color={ageMs(hold.at, now) > 3_600_000 ? 'warning' : undefined} dimColor={ageMs(hold.at, now) <= 3_600_000}>
451                {' '}
452                {age(hold.at, now)}
453              </Text>
454            </Box>
455          ))
456        )}
457      </Box>
458    )
459
460    return (
461      <Box flexDirection="column">
462        <Box>
463          <Box flexGrow={1}>
464            <Text color={isStale ? 'warning' : undefined} dimColor={!isStale}>
465              {isStale ? '▲ ' : ''}orch-dash {age(snap.generatedAt, now)} 전
466            </Text>
467            {isOrcaStale ? <Text color="warning"> ▲ Orca {age(snap.orca?.updatedAt ?? null, now)} 전</Text> : null}
468          </Box>
469          <Button key="refresh" label="↻" plain onPress={() => refresh($)} />
470        </Box>
471        <Button key="toggle-needs" plain onPress={toggle('needs')}>
472          {isOpen ? '▾' : '▸'} 결정·사람만 <Text bold>{total}</Text>
473          {isOpen ? '' : <Text dimColor> {summary}</Text>}
474        </Button>
475        {isOpen && decideItems.length > 0 ? <Text dimColor>  결정 대기</Text> : null}
476        {isOpen ? decideItems.map((item, i) => row(item, i + 1)) : null}
477        {isOpen && humanItems.length > 0 ? <Text dimColor>  사람만</Text> : null}
478        {isOpen ? humanItems.map((item, i) => row(item, decideItems.length + i + 1)) : null}
479        <Box>
480          <Box flexGrow={1}>
481            <Button key="toggle-work" plain onPress={toggle('work')}>
482              {isWorkOpen ? '▾' : '▸'} 작업 진행 <Text color="success">{filled}</Text>
483              <Text dimColor>{empty}</Text>
484              {rowCount ? ` ${done}/${rowCount}` : <Text dimColor>— 기록 없음</Text>}
485            </Button>
486          </Box>
487          {versions ? <Text dimColor>{versions}</Text> : null}
488        </Box>
489        {workSection}
490        {isWorkOpen && work.untrackedPrs ? (
491          <Link href={`${DASH_URL}/#/fleet/prs`} label={`  + 표에 없는 열린 PR ${work.untrackedPrs} ↗`} />
492        ) : null}
493        <Button key="toggle-automations" plain onPress={expand('section:automations')}>
494          {isAutoOpen ? '▾' : '▸'} 자동화 {autos.length}
495          {troubled.length || isAutoSourceBad ? (
496            <Text color="warning"> · ▲ {troubled.length || '수집 실패'}</Text>
497          ) : (
498            <Text dimColor> · 문제 없음</Text>
499          )}
500          {upcoming ? <Text dimColor> · 다음 {upcoming.name} {until(upcoming.nextAt, now)}</Text> : ''}
501        </Button>
502        {autoSection}
503        <Button key="toggle-relations" plain onPress={expand('section:relations')}>
504          {isRelOpen ? '▾' : '▸'} 관계 세션 {rel.lines.length}
505          {working ? <Text color="permission"> · ◉ 작업 중 {working}</Text> : ''}
506          {waiting ? <Text color="warning"> · ◍ 대기 {waiting}</Text> : ''}
507          {snap.holds.length ? <Text dimColor> · 자원 {snap.holds.length}</Text> : ''}
508        </Button>
509        {relSection}
510      </Box>
511    )
512  })
513}
514
hooks/model.ts 330 lines
1import type {
2  OrchPanelAutomation,
3  OrchPanelCell,
4  OrchPanelItem,
5  OrchPanelItemKind,
6  OrchPanelMark,
7  OrchPanelPhase,
8  OrchPanelPrChip,
9  OrchPanelSession,
10  OrchPanelSnapshot,
11  OrchPanelStageRow,
12  OrchPanelWork,
13} from '../types'
14
15// The same split as brief-status: what needs the owner's call, and what only a person at the keyboard can do.
16// PR items (changes_requested, ci_failed, ...) belong to the progress table, sync items to the dashboard.
17const KIND_OF: Record<string, OrchPanelItemKind> = {
18  decision: 'decide',
19  approval: 'decide',
20  question: 'decide',
21  prompt: 'human',
22  permission: 'human',
23  login: 'human',
24  run_command: 'human',
25}
26
27export const DASH_URL = 'http://127.0.0.1:4780'
28export const STALE_COLLECTOR_MS = 3 * 60_000
29export const STALE_ORCA_MS = 90_000
30
31type Raw = Record<string, unknown>
32
33const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null)
34
35export function stateDir(env: { ORCH_FLEET_STATE?: string; HOME?: string }): string {
36  const dir = env.ORCH_FLEET_STATE ?? '~/.local/state/agent-skills/dashboard'
37  return dir.startsWith('~/') && env.HOME ? env.HOME + dir.slice(1) : dir
38}
39
40function recommendLabel(raw: Raw): string | null {
41  const options = Array.isArray(raw.options) ? (raw.options as Raw[]) : []
42  const n = typeof raw.recommend === 'number' ? raw.recommend : 0
43  return str(options[n - 1]?.label)
44}
45
46export function snapshotOf(text: string, checkedAt: number, cwd: string, stagesText: string | null = null): OrchPanelSnapshot {
47  let state: Raw
48  try {
49    state = JSON.parse(text)
50  } catch (err) {
51    return { status: 'unreadable', checkedAt, error: String(err).slice(0, 200) }
52  }
53  const sessions = new Map<string, string>()
54  for (const s of (state.sessions as Raw[] | undefined) ?? []) {
55    if (str(s.id)) sessions.set(s.id as string, str(s.name) ?? (s.id as string))
56  }
57  const items: OrchPanelItem[] = []
58  for (const it of (state.items as Raw[] | undefined) ?? []) {
59    const kind = KIND_OF[it.type as string]
60    if (!kind || !str(it.key)) continue
61    const session = str(it.session)
62    items.push({
63      key: it.key as string,
64      kind,
65      type: it.type as string,
66      title: str(it.title) ?? '(제목 없음)',
67      at: str(it.at),
68      decision: str(it.decision),
69      recommend: recommendLabel(it),
70      session,
71      sessionName: session ? (sessions.get(session) ?? null) : null,
72    })
73  }
74  // Oldest first: the one waiting longest is the one most likely to be costing something.
75  items.sort((a, b) => parseAt(a.at) - parseAt(b.at))
76  const sources = (state.sources ?? {}) as Record<string, Raw | undefined>
77  const sourceOf = (raw: Raw | undefined) => (raw ? { updatedAt: str(raw.updated_at), isOk: raw.ok !== false } : null)
78  return {
79    status: 'ok',
80    checkedAt,
81    generatedAt: str(state.generated_at),
82    // The root id is `<id>::<worktree path>`: only the coordinator's own session gets toasts and an unasked pane.
83    isCoordinator: str(state.root)?.endsWith(`::${cwd}`) ?? false,
84    orca: sourceOf(sources.orca),
85    items,
86    work: workOf(stagesText, state),
87    sessions: sessionsOf(state, cwd),
88    automations: ((state.automations as Raw[] | undefined) ?? []).flatMap(a =>
89      str(a.id)
90        ? [{
91            id: a.id as string,
92            name: str(a.name) ?? (a.id as string),
93            rrule: str(a.rrule),
94            isEnabled: a.enabled !== false,
95            nextAt: str(a.next_run_at),
96            lastAt: str(a.last_run_at),
97            recent: ((a.recent as Raw[] | undefined) ?? []).map(r => ({
98              status: str(r.status) ?? 'unknown',
99              at: str(r.at),
100              summary: str(r.summary),
101            })),
102          }]
103        : [],
104    ),
105    automationSource: sourceOf(sources.automations),
106    holds: ((state.holds as Raw[] | undefined) ?? []).flatMap(raw =>
107      str(raw.resource)
108        ? [{
109            kind: raw.kind === 'lane' ? ('lane' as const) : ('resource' as const),
110            resource: raw.resource as string,
111            by: str(raw.by),
112            byName: sessions.get(str(raw.session) ?? '') ?? null,
113            note: str(raw.note),
114            at: str(raw.at),
115          }]
116        : [],
117    ),
118  }
119}
120
121const PHASES: readonly OrchPanelPhase[] = ['working', 'waiting', 'idle', 'open', 'offline']
122
123function sessionsOf(state: Raw, cwd: string): OrchPanelSession[] {
124  return ((state.sessions as Raw[] | undefined) ?? []).flatMap(s => {
125    if (!str(s.id)) return []
126    const pr = s.linked_pr as Raw | null | undefined
127    return [{
128      id: s.id as string,
129      name: str(s.name) ?? (s.id as string),
130      kind: str(s.kind) ?? 'task',
131      phase: PHASES.find(p => p === s.phase) ?? 'offline',
132      parent: str(s.parent),
133      task: str(s.task_title),
134      pr: typeof pr?.number === 'number' ? pr.number : null,
135      isPrMerged: pr?.state === 'merged',
136      isMe: (s.id as string).endsWith(`::${cwd}`),
137    }]
138  })
139}
140
141// The relations tree as drawn: offline sessions leave it (counted apart) unless something waits on them;
142// each line carries the prefix (`├─ `, `│  └─ `) its depth and place give it.
143export function tree(
144  sessions: OrchPanelSession[],
145  busy: Set<string>,
146): { lines: { session: OrchPanelSession; prefix: string }[]; offline: number } {
147  const shown = sessions.filter(s => s.phase !== 'offline' || busy.has(s.id))
148  const ids = new Set(shown.map(s => s.id))
149  const lines: { session: OrchPanelSession; prefix: string }[] = []
150  const walk = (parent: string | null, indent: string, seen: Set<string>) => {
151    const kids = shown.filter(s => (parent === null ? !s.parent || !ids.has(s.parent) : s.parent === parent))
152    kids.forEach((s, i) => {
153      if (seen.has(s.id)) return
154      const isLast = i === kids.length - 1
155      lines.push({ session: s, prefix: parent === null ? '' : indent + (isLast ? '└─ ' : '├─ ') })
156      walk(s.id, parent === null ? '' : indent + (isLast ? '   ' : '│  '), new Set([...seen, s.id]))
157    })
158  }
159  walk(null, '', new Set())
160  return { lines, offline: sessions.length - shown.length }
161}
162
163const MARKS: readonly OrchPanelMark[] = ['ok', 'fail', 'partial', 'checking', 'na']
164
165function cellOf(raw: unknown): OrchPanelCell | null {
166  const c = raw as Raw | undefined
167  const mark = MARKS.find(m => m === c?.mark)
168  return mark ? { mark, evidence: str(c?.evidence), by: str(c?.by) } : null
169}
170
171// The same reading as stages.py merge_cell: the merge column is never written, only read off the PR list.
172function mergeOf(pr: Raw): OrchPanelCell {
173  if (str(pr.merged_at) || pr.state === 'MERGED') {
174    const at = new Date(parseAt(str(pr.merged_at)))
175    return { mark: 'ok', evidence: at.getTime() ? `${at.getUTCMonth() + 1}/${at.getUTCDate()}` : '머지됨', by: null }
176  }
177  if (pr.state === 'CLOSED') return { mark: 'fail', evidence: '닫힘', by: null }
178  const why =
179    pr.ci === 'failure' ? 'CI 실패'
180    : pr.decision === 'CHANGES_REQUESTED' ? '변경 요청'
181    : pr.draft ? 'draft'
182    : '리뷰 대기'
183  return { mark: 'fail', evidence: why, by: null }
184}
185
186function chipOf(pr: Raw): OrchPanelPrChip | null {
187  if (pr.state !== 'OPEN') return null
188  if (pr.ci === 'failure') return { text: '✗CI', tone: 'error' }
189  if (pr.decision === 'CHANGES_REQUESTED') return { text: '✎', tone: 'warning' }
190  if (pr.ci === 'pending') return { text: '◐CI', tone: 'warning' }
191  if (pr.merge_state === 'CLEAN') return { text: '✓', tone: 'success' }
192  return null
193}
194
195export function workOf(stagesText: string | null, state: Raw): OrchPanelWork {
196  const prs = new Map<string, Raw>()
197  for (const p of (state.prs as Raw[] | undefined) ?? []) if (str(p.key)) prs.set(p.key as string, p)
198  let store: Raw = {}
199  let error: string | null = null
200  try {
201    store = stagesText ? JSON.parse(stagesText) : {}
202  } catch (err) {
203    error = String(err).slice(0, 200)
204  }
205  const byId = (store.rows ?? {}) as Record<string, Raw>
206  const order = ((store.order as string[] | undefined) ?? []).filter(id => byId[id])
207  const toRow = (id: string): OrchPanelStageRow => {
208    const r = byId[id] as Raw
209    const cells = (r.cells ?? {}) as Raw
210    const pr = str(r.pr)
211    const live = pr ? prs.get(pr) : undefined
212    return {
213      id,
214      title: str(r.title) ?? id,
215      pr,
216      isChild: !!str(r.parent),
217      isDone: !!str(r.done_at),
218      merge: live ? mergeOf(live) : cellOf(cells.merge),
219      dev: cellOf(cells.dev),
220      prod: cellOf(cells.prod),
221      chip: live ? chipOf(live) : null,
222    }
223  }
224  // Each parent followed by its children, in the order they were added: `orch stage show`'s order.
225  const rows = order
226    .filter(id => !str(byId[id]?.parent))
227    .flatMap(id => [toRow(id), ...order.filter(k => byId[k]?.parent === id).map(toRow)])
228  const tracked = new Set(rows.map(r => r.pr))
229  const versions = (store['env'] ?? {}) as Raw
230  return {
231    rows,
232    versions: { prod: str(versions.prod), dev: str(versions.dev) },
233    next: ((store.next as unknown[] | undefined) ?? []).filter((n): n is string => !!str(n)),
234    untrackedPrs: [...prs.values()].filter(p => p.state === 'OPEN' && !tracked.has(p.key as string)).length,
235    error,
236  }
237}
238
239// A row is through when it is marked done or every stage it has is verified.
240export function progress(work: OrchPanelWork): { done: number; total: number } {
241  const through = (r: OrchPanelStageRow) =>
242    r.isDone || [r.merge, r.dev, r.prod].every(c => c?.mark === 'ok' || c?.mark === 'na')
243  return { done: work.rows.filter(through).length, total: work.rows.length }
244}
245
246// `▰▰▰▱▱`: one cell per row up to ten, scaled past that.
247export function bar(done: number, total: number): { filled: string; empty: string } {
248  const width = Math.min(total, 10)
249  const filled = total ? Math.round((done / total) * width) : 0
250  return { filled: '▰'.repeat(filled), empty: '▱'.repeat(width - filled) }
251}
252
253export function counts(snapshot: OrchPanelSnapshot | null): { decide: number; human: number } {
254  const items = snapshot?.status === 'ok' ? snapshot.items : []
255  return {
256    decide: items.filter(i => i.kind === 'decide').length,
257    human: items.filter(i => i.kind === 'human').length,
258  }
259}
260
261// Plain text by necessity: the status line takes no color. Zero counts are left out, all zero clears it.
262export function statusText(snapshot: OrchPanelSnapshot | null): string | undefined {
263  if (snapshot?.status !== 'ok') return undefined
264  const { decide, human } = counts(snapshot)
265  const troubled = snapshot.automations.filter(a => automationIssue(a, snapshot.checkedAt)).length
266  const parts = [decide && `결정 ${decide}`, human && `사람만 ${human}`, troubled && `자동화 ▲${troubled}`].filter(Boolean)
267  return parts.length ? parts.join(' · ') : undefined
268}
269
270// The collector writes most times as `...Z`, but some sources as `+0900`, which Date.parse need not accept.
271export function parseAt(at: string | null): number {
272  return Date.parse((at ?? '').replace(/([+-]\d\d)(\d\d)$/, '$1:$2')) || 0
273}
274
275export function age(fromIso: string | null, now: number): string {
276  const t = parseAt(fromIso)
277  if (!t) return '?'
278  const s = Math.max(0, Math.round((now - t) / 1000))
279  if (s < 60) return `${s}s`
280  if (s < 3600) return `${Math.floor(s / 60)}m`
281  if (s < 86400) return `${Math.floor(s / 3600)}h`
282  return `${Math.floor(s / 86400)}d`
283}
284
285export function ageMs(fromIso: string | null, now: number): number {
286  const t = parseAt(fromIso)
287  return t ? now - t : Infinity
288}
289
290export function toastText(item: OrchPanelItem): string {
291  const head = item.kind === 'decide' ? '결정 대기' : '사람만'
292  return `${head}${item.decision ? ` ${item.decision}` : ''}: ${item.title}`
293}
294
295export function sessionUrl(session: string): string {
296  return `${DASH_URL}/#/fleet/session/${encodeURIComponent(session)}`
297}
298
299const STALL_MS = 10 * 60_000
300
301// What is wrong with an automation, if anything: its last run failed, or its next run is overdue (the scheduler
302// stopped, or the app was closed). A switched-off automation is not a problem.
303export function automationIssue(a: OrchPanelAutomation, now: number): string | null {
304  if (!a.isEnabled) return null
305  if (a.recent[0] && /fail|error/.test(a.recent[0].status)) return '마지막 실행 실패'
306  if (a.nextAt && now - parseAt(a.nextAt) > STALL_MS) return `예정 ${age(a.nextAt, now)} 지남`
307  return null
308}
309
310// The few cron shapes automations use, in words; anything else stays as written.
311export function cadence(rrule: string | null): string {
312  if (!rrule) return '?'
313  const m = (re: RegExp) => rrule.trim().match(re)
314  let g = m(/^(\d+) \*\/(\d+) \* \* \*$/)
315  if (g) return `${g[2]}시간마다 :${g[1]?.padStart(2, '0')}`
316  g = m(/^\*\/(\d+) (\d+)-(\d+) \* \* 1-5$/)
317  if (g) return `평일 ${g[2]}–${g[3]}시 ${g[1]}분마다`
318  g = m(/^\*\/(\d+) \* \* \* \*$/)
319  if (g) return `${g[1]}분마다`
320  g = m(/^(\d+) (\d+) \* \* \*$/)
321  if (g) return `매일 ${g[2]}:${g[1]?.padStart(2, '0')}`
322  return rrule
323}
324
325export function until(iso: string | null, now: number): string {
326  const t = parseAt(iso)
327  if (!t) return '–'
328  return t >= now ? `${age(new Date(now).toISOString(), t)} 후` : `${age(iso, now)} 지남`
329}
330
types/index.d.ts 112 lines
1// Kept small on purpose: the session holds only what the panes draw, never the collector's whole state.json.
2
3export type OrchPanelItemKind = 'decide' | 'human'
4
5export type OrchPanelItem = {
6  key: string
7  kind: OrchPanelItemKind
8  type: string
9  title: string
10  at: string | null
11  decision: string | null
12  recommend: string | null
13  session: string | null
14  sessionName: string | null
15}
16
17export type OrchPanelSource = { updatedAt: string | null; isOk: boolean }
18
19export type OrchPanelMark = 'ok' | 'fail' | 'partial' | 'checking' | 'na'
20
21export type OrchPanelCell = { mark: OrchPanelMark; evidence: string | null; by: string | null }
22
23// A PR's live state beside its key: what holds it, or that it is ready to merge.
24export type OrchPanelPrChip = { text: string; tone: 'success' | 'warning' | 'error' }
25
26export type OrchPanelStageRow = {
27  id: string
28  title: string
29  pr: string | null
30  isChild: boolean
31  isDone: boolean
32  merge: OrchPanelCell | null
33  dev: OrchPanelCell | null
34  prod: OrchPanelCell | null
35  chip: OrchPanelPrChip | null
36}
37
38// The `orch stage` record (stages.json) with its merge cells filled from state.json's PR list, as `orch stage show`.
39export type OrchPanelWork = {
40  rows: OrchPanelStageRow[]
41  versions: { prod: string | null; dev: string | null }
42  next: string[]
43  untrackedPrs: number
44  error: string | null
45}
46
47export type OrchPanelPhase = 'working' | 'waiting' | 'idle' | 'open' | 'offline'
48
49// One Orca session of the fleet tree: what the relations section draws, nothing more.
50export type OrchPanelSession = {
51  id: string
52  name: string
53  kind: string
54  phase: OrchPanelPhase
55  parent: string | null
56  task: string | null
57  pr: number | null
58  isPrMerged: boolean
59  isMe: boolean
60}
61
62// A landing lane's lock or a resource taken with `orch hold`, as the collector listed it.
63export type OrchPanelHold = {
64  kind: 'lane' | 'resource'
65  resource: string
66  by: string | null
67  byName: string | null
68  note: string | null
69  at: string | null
70}
71
72export type OrchPanelRun = { status: string; at: string | null; summary: string | null }
73
74// An Orca automation as the collector listed it, its runs newest first.
75export type OrchPanelAutomation = {
76  id: string
77  name: string
78  rrule: string | null
79  isEnabled: boolean
80  nextAt: string | null
81  lastAt: string | null
82  recent: OrchPanelRun[]
83}
84
85export type OrchPanelSnapshot =
86  | { status: 'missing'; checkedAt: number }
87  | { status: 'unreadable'; checkedAt: number; error: string }
88  | {
89      status: 'ok'
90      checkedAt: number
91      generatedAt: string | null
92      isCoordinator: boolean
93      orca: OrchPanelSource | null
94      items: OrchPanelItem[]
95      work: OrchPanelWork
96      sessions: OrchPanelSession[]
97      holds: OrchPanelHold[]
98      automations: OrchPanelAutomation[]
99      automationSource: OrchPanelSource | null
100    }
101
102declare module 'claude-code' {
103  interface PluginState {
104    'orch-panel': {
105      snapshot: OrchPanelSnapshot | null
106      seen: string[] | null
107      collapsed: string[]
108      expanded: string[]
109    }
110  }
111}
112