SLOPSHOPPER

work-progress

여러 세션의 업무 진행도를 업무 단위로 모아 입력창 위 한 줄과 /progress 패널로 보여 준다. 단계를 노드로 이어 그리고, 완료 뒤 남은 일과 닫히지 않은 서브에이전트 기록을 챙긴다. 남은 단계와 지난 기록으로 예상 남은 시간도 보여 준다

newpanebandguardcommandprompt
v0.3.0MITupdated 2026-10-16wonjangcloud9/wj-mods/work-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-progress
│ ┃ 업무 진행도 ✕ › 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 │ │ › /progress │ ⎿ work-progress: 업무 진행도 패널을 열었어요. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 업무 진행도
기록된 업무가 없어요.
README

wj-mods

English · 한국어

A set of Claude Code mods that change the terminal UI and notifications. They are plugins with hook modules, installable one by one from this marketplace.

The UI text, notifications and summaries are written in Korean.

ModWhat it does
work-progressCollects multi-step work from every open session into a line above the prompt and a /progress pane. Each task's steps are drawn as a chain of nodes that opens into a vertical flow. It also keeps follow-ups left after a task is done, what a paused task is waiting for, and subagent records that ended without being closed. Each open task shows the time likely left, judged from how long its own steps and your recent tasks' steps took. /progress 요약 writes a summary of the last 24 hours.
plain-koreanA Korean readability bundle. An output style for short, plain Korean answers (conclusion first, no made-up abbreviations, nothing referred to by number alone), a guard that flags answers that slip into English and corrects the next one, a nudge to re-explain when you type "뭔 말" or "짧게", and the claude-warm theme. Installing it also installs plain-summary, turn-divider and compact-tool-rows.
usage-bandShows 5-hour and weekly usage and the Claude status page state in one line above the prompt.
long-turn-notifySends a macOS notification and plays a voice line when a turn ran longer than 2 minutes, or when a question or permission prompt is waiting. macOS only.
session-nicknameGives each new session a random name so other sessions can address it with SendMessage. The name reaches the session list with the first prompt.
turn-dividerDraws a divider above each of your prompts to separate questions from answers.
plain-summaryAdds a two-sentence plain-language summary under long, report-style answers, written by a small model.
compact-tool-rowsShortens long Bash commands in tool rows to the start of the first line.

work-progress pane:

업무 3개 · ● 작업 중 1 · □ 남은 일 1 · ✓ 오늘 완료 1

── ● 작업 중 1 ─────────────────────────────────────────────
▸ Theme builder spec
  ●━●━◉─○─○─○ 2/6 · 약 2시간 남음 · 이 단계 1시간째 · 5분 전 · @quiet-otter
  지금: Write the requirements
  최근: Bash Run the mod's tests · 방금
  └ ● Benchmark AI app builders 1/8 · 최근: WebSearch Lovable docs
  └ ‖ Seed starter audit 3/4 · 보류로 끝남 · only saving the file is left
  └ ✓ 서브에이전트 5개 끝남

── □ 남은 일 1 ─────────────────────────────────────────────
▸ Fix lost attachments · 1일 전
  □ push
── ✓ 오늘 완료 1 ───────────────────────────────────────────
▸ Release notes draft · 2시간 전

Click a task to open its steps as a flow, with the time each step took and its evidence:

▾ Theme builder spec
  ● Read the brief · 40분 — notes in brief.md
  ┃
  ● Benchmark · 1시간 20분 — 3 reports
  ┃
  ◉ Write the requirements  ← 지금
  │
  ○ Design

Install

From the marketplace:

claude plugin marketplace add wonjangcloud9/wj-mods
claude plugin install work-progress@wj-mods

Install other mods the same way with <mod>@wj-mods. They load in sessions started after installing.

The Korean readability bundle installs plain-summary, turn-divider and compact-tool-rows with it:

claude plugin install plain-korean@wj-mods

The output style and the theme are not switched on for you. Choose plain-korean as the output style in /config, and claude-warm in /theme if you want the colors too.

From a local clone, list the mod folders in CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, separated by :. Folders listed this way do not pull in dependencies, so list the bundle's three mods as well:

git clone https://github.com/wonjangcloud9/wj-mods.git ~/wj-mods
{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/me/wj-mods/work-progress:/Users/me/wj-mods/usage-band"
  }
}

Notes

  • Tested with Claude Code 2.1.301.
  • long-turn-notify records its voice lines with the macOS say Yuna voice on first use and keeps them in ~/.claude/long-turn-notify/sounds/. Without that voice it shows the notification silently.
  • plain-summary calls a small model once per long answer, which counts toward your usage.
  • work-progress keeps its records in ~/.claude/work-progress/ on your machine.
  • Run a mod's tests with claude plugin test <mod folder>.

License

MIT

Source 7 files
hooks/register.tsx 167 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { readLive, readSessions, readWork, readWorks, writeLive, writeWork } from './files'
5import {
6  TOOL_DESCRIPTION,
7  TOOL_NAME,
8  TOOL_SCHEMA,
9  MAIN_LOOP,
10  closingNote,
11  describeCall,
12  guideText,
13  liveState,
14  parentsBySession,
15  progressOf,
16  pruneNotes,
17  runningIds,
18  sameBoard,
19  toBoard,
20  toReport,
21  unclosedChildren,
22} from './model'
23import { SUMMARY_ARGS, dailySummary } from './summary'
24import { densityOf, drawPane, drawSummary } from './view'
25
26const board = atom({ plugin: 'work-progress', key: 'board' } as const, null)
27const expanded = atom({ plugin: 'work-progress', key: 'expanded' } as const, null)
28const snapshot = atom({ plugin: 'work-progress', key: 'snapshot' } as const, null)
29const published = atom({ plugin: 'work-progress', key: 'live' } as const, null)
30const notes = atom({ plugin: 'work-progress', key: 'activity' } as const, null)
31
32const PANE = 'work-progress'
33const PANE_TITLE = '업무 진행도'
34const COMMAND = 'progress'
35const TOOL = 'mcp__work-progress__report'
36const REFRESH_MS = 10_000
37const CLOCK_STEP_MS = 60_000
38
39// 다른 세션은 이 세션의 서브에이전트와 도구 사용을 볼 수 없으므로, 바뀔 때마다 파일로 알린다.
40async function publishLive($: EngineInterface): Promise<void> {
41  const [list, seen] = await Promise.all([$.agent.list(), read($, notes)])
42  const live = liveState(list, seen ?? {})
43  await update($, notes, prev => (prev ? pruneNotes(prev, live) : prev))
44  const text = JSON.stringify(live)
45  if ((await read($, published)) === text) return
46  await writeLive($, await $.session.id(), live)
47  await update($, published, () => text)
48}
49
50async function noteActivity($: EngineInterface, call: Record<string, unknown>): Promise<void> {
51  const line = describeCall(call)
52  if (!line) return
53  const loop = typeof call.agentId === 'string' ? call.agentId : MAIN_LOOP
54  const at = await $.clock.now()
55  await update($, notes, prev => ({ ...prev, [loop]: { text: line, at } }))
56}
57
58// 다른 세션의 기록은 파일로만 들어오므로 주기적으로 다시 읽는다. 내용이 같으면 시각이 1분 넘게 흘렀을 때만 다시 그린다.
59async function refresh($: EngineInterface): Promise<void> {
60  await publishLive($).catch(() => undefined)
61  const [works, sessions, live, now] = await Promise.all([readWorks($), readSessions($), readLive($), $.clock.now()])
62  const next = toBoard(works, sessions, now, live)
63  await update($, board, prev => (prev && sameBoard(prev, next) && now - prev.at < CLOCK_STEP_MS ? prev : next))
64}
65
66async function sessionName($: EngineInterface, id: string): Promise<string> {
67  const live = (await readSessions($)).find(s => s.sessionId === id)
68  return live?.name ?? id.slice(0, 8)
69}
70
71// 메인 대화가 업무를 기록할 때, 그 아래 서브에이전트가 닫지 않고 끝난 기록을 알려 준다.
72async function leftOpen($: EngineInterface, parentId: string): Promise<string[]> {
73  const running = runningIds(await $.agent.list())
74  return closingNote(unclosedChildren(await readWorks($), parentId, running))
75}
76
77async function archive($: EngineInterface, id: string): Promise<void> {
78  const work = await readWork($, id)
79  if (!work) return
80  await writeWork($, { ...work, status: 'archived', updatedAt: await $.clock.now() })
81  await update($, expanded, () => null)
82  await refresh($)
83}
84
85export const register: Register = on => {
86  on('session.start', async ($, e, next) => {
87    const result = await next(e)
88    await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA, isDeferred: false })
89    await $.command.register({
90      name: COMMAND,
91      description: '모든 세션의 업무 진행도를 패널로 연다. /progress 요약 은 최근 24시간 업무를 글로 정리한다',
92    })
93    await refresh($)
94    // hot reload 때도 session.start 가 다시 오지만 이미 만든 안내는 그대로 둔다.
95    const current = await read($, board)
96    await update($, snapshot, prev => prev ?? guideText(current))
97    $.clock.every(REFRESH_MS, () => void refresh($))
98    return result
99  })
100
101  on('tool.call', { tool: TOOL }, async ($, e) => {
102    const sessionId = await $.session.id()
103    const at = await $.clock.now()
104    const input = e as Record<string, unknown>
105    const id = typeof input.work_id === 'string' ? input.work_id : ''
106    const previous = id ? await readWork($, id.trim().toLowerCase()) : null
107    const agentId = typeof input.agentId === 'string' ? input.agentId : undefined
108    const parentId = agentId ? parentsBySession(await readWorks($)).get(sessionId)?.id : undefined
109    const by = { sessionId, sessionName: await sessionName($, sessionId), agentId, parentId, at }
110    const report = toReport(input, previous, by)
111    if ('error' in report) return { deny: report.error }
112
113    await writeWork($, report.work)
114    await refresh($)
115    const { done, total } = progressOf(report.work)
116    const children = agentId ? [] : await leftOpen($, report.work.id).catch(() => [])
117    const lines = [`기록함: ${report.work.title} ${done}/${total} (${report.work.status})`, ...report.notes, ...children]
118    return { result: lines.join('\n') }
119  }).catch(() => ({ deny: '진행 기록을 저장하지 못했어요. 작업은 그대로 이어 가세요.' }))
120
121  // 모든 도구 호출을 지켜보되 막지 않는다. 기록에 실패해도 도구는 그대로 돈다.
122  on('tool.call', async ($, e, next) => {
123    await noteActivity($, e as Record<string, unknown>).catch(() => undefined)
124    return next(e)
125  })
126
127  on('prompt.compose', async ($, e, next) => {
128    const result = await next(e)
129    const text = (await read($, snapshot)) ?? guideText(null)
130    return { sections: [...result.sections, { id: 'work-progress:guide', text, scope: 'session' as const }] }
131  })
132
133  on('command.run', { command: COMMAND }, async ($, e) => {
134    if (SUMMARY_ARGS.includes(e.args.trim().toLowerCase())) {
135      return { text: dailySummary(await readWorks($), await $.clock.now()) }
136    }
137    await refresh($)
138    const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
139    return { text: opened.isPlaced ? '업무 진행도 패널을 열었어요.' : `패널을 열지 못했어요: ${opened.reason}` }
140  }).catch(() => ({ text: '업무 진행도 패널을 열지 못했어요.' }))
141
142  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
143    const els = $.ui.resolve(e)
144    const [current, open] = await Promise.all([read($, board), read($, expanded)])
145    return drawPane(els, current, open, e.props.bodyColumns, {
146      onToggle: id => void update($, expanded, prev => (prev === id ? null : id)),
147      onArchive: id => void archive($, id),
148    })
149  })
150
151  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152    const below = await next(e)
153    if (e.props.hasSurvey) return below
154    const current = await read($, board)
155    if (!current || (current.rows.length === 0 && current.untracked.length === 0)) return below
156
157    const els = $.ui.resolve(e)
158    const { Box } = els
159    return (
160      <Box flexDirection="column">
161        {drawSummary(els, current, densityOf(e.props.bodyColumns), true)}
162        {below}
163      </Box>
164    )
165  })
166}
167
hooks/files.ts 64 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { LiveSession, SessionLive, Work } from '../types'
4import { toLiveSession, toSessionLive, toWork } from './model'
5
6type Dirs = { works: string; sessions: string; live: string }
7
8// 업무 기록은 세션마다 다른 mod 인스턴스가 함께 쓰므로 $.store 대신 업무별 파일로 나눈다.
9async function dirs($: EngineInterface): Promise<Dirs> {
10  const home = (await $.env.get('HOME')) ?? ''
11  return {
12    works: `${home}/.claude/work-progress/works`,
13    sessions: `${home}/.claude/sessions`,
14    live: `${home}/.claude/work-progress/live`,
15  }
16}
17
18async function readJsonFiles($: EngineInterface, dir: string): Promise<unknown[]> {
19  const entries = await $.fs.list(dir).catch(() => [])
20  const files = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
21  const values = await Promise.all(
22    files.map(entry =>
23      $.fs
24        .read(`${dir}/${entry.name}`)
25        .then(raw => JSON.parse(raw) as unknown)
26        .catch(() => null),
27    ),
28  )
29  return values
30}
31
32export async function readWorks($: EngineInterface): Promise<Work[]> {
33  const values = await readJsonFiles($, (await dirs($)).works)
34  return values.flatMap(value => toWork(value) ?? [])
35}
36
37export async function readWork($: EngineInterface, id: string): Promise<Work | null> {
38  const raw = await $.fs.read(`${(await dirs($)).works}/${id}.json`).catch(() => null)
39  if (raw === null) return null
40  try {
41    return toWork(JSON.parse(raw))
42  } catch {
43    return null
44  }
45}
46
47export async function writeWork($: EngineInterface, work: Work): Promise<void> {
48  await $.fs.write(`${(await dirs($)).works}/${work.id}.json`, `${JSON.stringify(work, null, 2)}\n`)
49}
50
51export async function readSessions($: EngineInterface): Promise<LiveSession[]> {
52  const values = await readJsonFiles($, (await dirs($)).sessions)
53  return values.flatMap(value => toLiveSession(value) ?? [])
54}
55
56export async function readLive($: EngineInterface): Promise<Map<string, SessionLive>> {
57  const values = await readJsonFiles($, (await dirs($)).live)
58  return new Map(values.flatMap(value => toSessionLive(value) ?? []).map(({ sessionId, live }) => [sessionId, live]))
59}
60
61export async function writeLive($: EngineInterface, sessionId: string, live: SessionLive): Promise<void> {
62  await $.fs.write(`${(await dirs($)).live}/${sessionId}.json`, `${JSON.stringify({ sessionId, ...live }, null, 2)}\n`)
63}
64
hooks/model.ts 492 lines
1import type {
2  Activity,
3  Board,
4  ChildRow,
5  LiveAgent,
6  LiveSession,
7  Phase,
8  SessionLive,
9  Step,
10  Work,
11  WorkRow,
12  WorkStatus,
13} from '../types'
14import { paceOf } from './pace'
15
16export { stepSpans, stepStartedAt } from './pace'
17
18const DAY_MS = 24 * 60 * 60_000
19const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/
20export const PHASE_ORDER: Phase[] = ['yourTurn', 'running', 'stalled', 'paused', 'followUp', 'done', 'dormant']
21const REPORTED_STATUS: WorkStatus[] = ['active', 'paused', 'done']
22const ACTIVE_AGENT = ['pending', 'running', 'waiting']
23const ACTIVITY_LENGTH = 80
24export const MAIN_LOOP = 'main'
25
26function text(value: unknown): string | undefined {
27  return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
28}
29
30function toStep(value: unknown): Step | null {
31  const { title, isDone, done, evidence, doneAt } = (value ?? {}) as Record<string, unknown>
32  const name = text(title)
33  if (!name) return null
34  const flag = typeof isDone === 'boolean' ? isDone : done === true
35  const proof = text(evidence)
36  const step: Step = proof ? { title: name, isDone: flag, evidence: proof } : { title: name, isDone: flag }
37  return flag && typeof doneAt === 'number' ? { ...step, doneAt } : step
38}
39
40function toSteps(value: unknown): Step[] {
41  return Array.isArray(value) ? value.flatMap(item => toStep(item) ?? []) : []
42}
43
44function toLines(value: unknown): string[] | undefined {
45  if (!Array.isArray(value)) return undefined
46  const lines = value.flatMap(item => text(item) ?? [])
47  return lines.length > 0 ? lines : undefined
48}
49
50export function toWork(value: unknown): Work | null {
51  const v = (value ?? {}) as Record<string, unknown>
52  const id = text(v.id)
53  const title = text(v.title)
54  const sessionId = text(v.sessionId)
55  const status = v.status as WorkStatus
56  if (!id || !title || !sessionId || typeof v.updatedAt !== 'number') return null
57  if (!['active', 'paused', 'done', 'archived'].includes(status)) return null
58  return {
59    id,
60    title,
61    steps: toSteps(v.steps),
62    now: text(v.now),
63    question: text(v.question),
64    waitingFor: text(v.waitingFor),
65    followups: toLines(v.followups),
66    status,
67    sessionId,
68    sessionName: text(v.sessionName) ?? sessionId.slice(0, 8),
69    agentId: text(v.agentId),
70    parentId: text(v.parentId),
71    createdAt: typeof v.createdAt === 'number' ? v.createdAt : v.updatedAt,
72    updatedAt: v.updatedAt,
73    doneAt: typeof v.doneAt === 'number' ? v.doneAt : undefined,
74  }
75}
76
77// ~/.claude/sessions/<pid>.json 은 엔진이 살아 있는 세션마다 남기는 기록이다.
78export function toLiveSession(value: unknown): LiveSession | null {
79  const { sessionId, name, status } = (value ?? {}) as Record<string, unknown>
80  const id = text(sessionId)
81  if (!id) return null
82  return { sessionId: id, name: text(name) ?? id.slice(0, 8), isBusy: status === 'busy' }
83}
84
85function baseName(path: unknown): string | undefined {
86  return text(path)?.split('/').filter(Boolean).pop()
87}
88
89function detailOf(call: Record<string, unknown>): string | undefined {
90  switch (call.tool) {
91    // 명령 본문에는 비밀값이 섞일 수 있어 설명이 없으면 프로그램 이름만 쓴다.
92    case 'Bash':
93      return text(call.description) ?? text(call.command)?.split(/\s+/)[0]
94    case 'Agent':
95      return text(call.description)
96    case 'Read':
97    case 'Edit':
98    case 'Write':
99      return baseName(call.file_path)
100    case 'NotebookEdit':
101      return baseName(call.notebook_path)
102    case 'Grep':
103    case 'Glob':
104      return text(call.pattern)
105    case 'WebSearch':
106      return text(call.query)
107    case 'Skill':
108      return text(call.skill)
109    default:
110      return undefined
111  }
112}
113
114// 도구 호출을 패널의 "최근" 한 줄로 바꾼다. 진행 기록 도구 자체는 활동으로 치지 않는다.
115export function describeCall(call: Record<string, unknown>): string | undefined {
116  const tool = text(call.tool)
117  if (!tool || tool === `mcp__work-progress__${TOOL_NAME}`) return undefined
118  const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' ') : tool
119  const detail = detailOf(call)
120  return [...(detail ? `${name} ${detail}` : name)].slice(0, ACTIVITY_LENGTH).join('')
121}
122
123// 이 세션이 다른 세션에 알릴 상태. 아직 도는 서브에이전트와 각자의 최근 활동만 남긴다.
124export function liveState(
125  list: { id: string; description: string; status: string }[],
126  notes: Record<string, Activity>,
127): SessionLive {
128  const agents = list
129    .filter(agent => ACTIVE_AGENT.includes(agent.status))
130    .map(({ id, description }) => (notes[id] ? { id, description, activity: notes[id] } : { id, description }))
131  const main = notes[MAIN_LOOP]
132  return main ? { activity: main, agents } : { agents }
133}
134
135export function runningIds(list: { id: string; status: string }[]): Set<string> {
136  return new Set(list.filter(agent => ACTIVE_AGENT.includes(agent.status)).map(agent => agent.id))
137}
138
139// 끝난 서브에이전트의 활동은 버린다.
140export function pruneNotes(notes: Record<string, Activity>, live: SessionLive): Record<string, Activity> {
141  const keep = new Set([MAIN_LOOP, ...live.agents.map(agent => agent.id)])
142  return Object.fromEntries(Object.entries(notes).filter(([loop]) => keep.has(loop)))
143}
144
145function toActivity(value: unknown): Activity | undefined {
146  const { text: line, at } = (value ?? {}) as Record<string, unknown>
147  const said = text(line)
148  return said && typeof at === 'number' ? { text: said, at } : undefined
149}
150
151export function toSessionLive(value: unknown): { sessionId: string; live: SessionLive } | null {
152  const { sessionId, agents, activity } = (value ?? {}) as Record<string, unknown>
153  const id = text(sessionId)
154  if (!id || !Array.isArray(agents)) return null
155  const list: LiveAgent[] = agents.flatMap(item => {
156    const { id: agentId, description, activity: done } = (item ?? {}) as Record<string, unknown>
157    const key = text(agentId)
158    if (!key) return []
159    const recent = toActivity(done)
160    const agent = { id: key, description: text(description) ?? key }
161    return [recent ? { ...agent, activity: recent } : agent]
162  })
163  const main = toActivity(activity)
164  return { sessionId: id, live: main ? { activity: main, agents: list } : { agents: list } }
165}
166
167export function toId(value: unknown): string | undefined {
168  const raw = text(value)?.toLowerCase()
169  if (!raw) return undefined
170  const id = raw.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 64)
171  return ID_PATTERN.test(id) ? id : undefined
172}
173
174export type Reporter = { sessionId: string; sessionName: string; agentId?: string; parentId?: string; at: number }
175export type Report = { work: Work; notes: string[] } | { error: string }
176
177export function toReport(input: Record<string, unknown>, previous: Work | null, by: Reporter): Report {
178  const id = toId(input.work_id)
179  if (!id) return { error: 'work_id 는 영문 소문자, 숫자, 하이픈으로 적어 주세요.' }
180  const title = text(input.title) ?? previous?.title
181  if (!title) return { error: 'title 에 업무 목표를 한 줄로 적어 주세요.' }
182  const steps = toSteps(input.steps)
183  if (steps.length === 0) return { error: 'steps 에 전체 단계 목록을 한 개 이상 보내 주세요.' }
184
185  const asked = input.status as WorkStatus
186  const status = REPORTED_STATUS.includes(asked) ? asked : 'active'
187  const isNewlyDone = status === 'done' && previous?.status !== 'done'
188  // 메인 대화가 서브에이전트의 기록을 대신 닫아도 부모 업무 아래에 그대로 둔다.
189  const keepsSub = !by.agentId && previous !== null && isSubWork(previous)
190  const work: Work = {
191    id,
192    title,
193    steps: timeSteps(steps, previous, by.at),
194    now: text(input.now),
195    question: text(input.question),
196    waitingFor: text(input.waiting_for),
197    // 남은 일은 잊히지 않도록, 보내지 않으면 이전 목록을 그대로 둔다.
198    followups: 'followups' in input ? toLines(input.followups) : previous?.followups,
199    status,
200    sessionId: by.sessionId,
201    sessionName: by.sessionName,
202    agentId: by.agentId ?? (keepsSub ? previous.agentId : undefined),
203    parentId: by.agentId ? previous?.parentId ?? by.parentId : keepsSub ? previous.parentId : undefined,
204    createdAt: previous?.createdAt ?? by.at,
205    updatedAt: by.at,
206    doneAt: status === 'done' ? (isNewlyDone ? by.at : previous?.doneAt ?? by.at) : undefined,
207  }
208  const unproven = steps.filter(step => step.isDone && !step.evidence).map(step => step.title)
209  const notes = unproven.length > 0 ? [`근거 없이 완료로 표시한 단계: ${unproven.join(', ')}`] : []
210  return { work, notes }
211}
212
213// 단계를 처음 완료로 표시한 시각을 남긴다. 이전 기록에서 같은 이름, 없으면 같은 자리의 단계를 이어 본다.
214// 시각 없이 완료돼 있던 예전 기록의 단계는 지금 끝낸 것으로 치지 않는다.
215function timeSteps(steps: Step[], previous: Work | null, at: number): Step[] {
216  return steps.map((step, index) => {
217    if (!step.isDone) return { ...step, doneAt: undefined }
218    const before = previous?.steps.find(item => item.title === step.title) ?? previous?.steps[index]
219    if (before?.isDone) return before.doneAt === undefined ? { ...step, doneAt: undefined } : { ...step, doneAt: before.doneAt }
220    return { ...step, doneAt: at }
221  })
222}
223
224// 서브에이전트가 끝났는데 완료로 닫지 않은 기록. 부모 대화가 마무리했으면 대신 닫아야 한다.
225export function unclosedChildren(works: Work[], parentId: string, running: Set<string>): Work[] {
226  return works.filter(
227    work =>
228      work.parentId === parentId &&
229      (work.status === 'active' || work.status === 'paused') &&
230      !(work.agentId && running.has(work.agentId)),
231  )
232}
233
234export function closingNote(open: Work[]): string[] {
235  if (open.length === 0) return []
236  const lines = open.map(work => {
237    const { done, total } = progressOf(work)
238    const state = work.status === 'paused' ? '보류' : '진행 중'
239    const reason = work.waitingFor ?? work.now
240    return `- ${work.id} · ${work.title} · ${state} ${done}/${total}${reason ? ` · ${reason}` : ''}`
241  })
242  return [
243    `서브에이전트가 닫지 않고 끝난 기록 ${open.length}개가 이 업무 아래 남아 있어요. 그 일을 마무리했으면 각 work_id 로 status done 을 기록해 주세요.`,
244    ...lines,
245  ]
246}
247
248export function progressOf(work: Work): { done: number; total: number; percent: number } {
249  const total = work.steps.length
250  const done = work.steps.filter(step => step.isDone).length
251  return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
252}
253
254// 완료 업무는 하루 뒤 숨기지만, 남은 일이 있으면 그 일을 끝내거나 보관할 때까지 남긴다.
255function isVisible(work: Work, now: number): boolean {
256  if (work.status === 'archived') return false
257  if (work.status === 'done') return Boolean(work.followups) || now - (work.doneAt ?? work.updatedAt) < DAY_MS
258  return true
259}
260
261function newest(works: Work[]): Work | undefined {
262  return works.reduce<Work | undefined>((best, work) => (!best || best.updatedAt < work.updatedAt ? work : best), undefined)
263}
264
265function childOf(agent: LiveAgent, record: Work | undefined): ChildRow {
266  const recent = agent.activity ? { activity: agent.activity } : {}
267  if (!record) return { id: agent.id, title: agent.description, ...recent }
268  const { done, total } = progressOf(record)
269  return { id: agent.id, title: record.title, done, total, ...recent }
270}
271
272function finishedOf(record: Work): ChildRow {
273  const { done, total } = progressOf(record)
274  const reason = record.waitingFor ?? record.now
275  return { id: record.id, title: record.title, done, total, status: record.status, ...(reason ? { now: reason } : {}) }
276}
277
278export function isSubWork(work: Work): boolean {
279  return Boolean(work.agentId || work.parentId)
280}
281
282// 서브에이전트를 붙일 세션별 부모. 진행 중인 업무가 없으면 가장 최근에 보류한 업무를 쓴다.
283export function parentsBySession(works: Work[]): Map<string, Work> {
284  const parents = new Map<string, Work>()
285  for (const work of works) {
286    if (isSubWork(work) || (work.status !== 'active' && work.status !== 'paused')) continue
287    const seen = parents.get(work.sessionId)
288    const isBetter = !seen || (seen.status === work.status ? seen.updatedAt < work.updatedAt : work.status === 'active')
289    if (isBetter) parents.set(work.sessionId, work)
290  }
291  return parents
292}
293
294// 서브에이전트가 일하는 동안에는 메인 대화가 입력을 기다리거나 보류로 적어 두었어도 그 업무는 작업 중이다.
295// 멈춤과 보류는 하루 넘게 기록이 없으면 오래 멈춤으로 접는다.
296function phaseOf(work: Work, live: LiveSession | undefined, isCurrent: boolean, hasAgents: boolean, now: number): Phase {
297  if (work.status === 'done') return work.followups ? 'followUp' : 'done'
298  if (hasAgents) return 'running'
299  const isOld = now - work.updatedAt >= DAY_MS
300  if (work.status === 'paused') return isOld ? 'dormant' : 'paused'
301  if (!live || !isCurrent) return isOld ? 'dormant' : 'stalled'
302  return live.isBusy ? 'running' : 'yourTurn'
303}
304
305export function toBoard(
306  works: Work[],
307  sessions: LiveSession[],
308  now: number,
309  live: Map<string, SessionLive> = new Map(),
310): Board {
311  const bySession = new Map(sessions.map(s => [s.sessionId, s]))
312  // 끝난 세션이 남긴 상태는 믿지 않는다.
313  const stateOf = (sessionId: string) => (bySession.has(sessionId) ? live.get(sessionId) : undefined)
314  const agentsOf = (sessionId: string) => stateOf(sessionId)?.agents ?? []
315  const recordOf = (agentId: string) => newest(works.filter(work => work.agentId === agentId))
316
317  const parents = parentsBySession(works)
318  const paceOfWork = (work: Work) => {
319    if (work.status !== 'active' && work.status !== 'paused') return {}
320    const paceMs = paceOf(work, works.filter(other => isSubWork(other) === isSubWork(work)))
321    return paceMs === undefined ? {} : { paceMs }
322  }
323  // 한 세션이 업무를 여러 개 열어 두었으면 메인 대화가 가장 최근에 기록한 진행 중 업무만 그 세션의 현재 업무로 본다.
324  const latest = new Map([...parents].filter(([, work]) => work.status === 'active'))
325
326  const mainRows: WorkRow[] = works
327    .filter(work => !isSubWork(work) && isVisible(work, now))
328    .map(work => {
329      const isCurrent = latest.get(work.sessionId)?.id === work.id
330      const isParent = parents.get(work.sessionId)?.id === work.id
331      const running = isParent ? agentsOf(work.sessionId) : []
332      const runningIds = new Set(running.map(agent => agent.id))
333      const activity = isParent ? stateOf(work.sessionId)?.activity : undefined
334      const finished = works
335        .filter(record => record.parentId === work.id && !(record.agentId && runningIds.has(record.agentId)))
336        .sort((a, b) => a.createdAt - b.createdAt)
337      return {
338        work,
339        phase: phaseOf(work, bySession.get(work.sessionId), isCurrent, running.length > 0, now),
340        children: running.map(agent => childOf(agent, recordOf(agent.id))),
341        finished: finished.map(finishedOf),
342        ...(activity ? { activity } : {}),
343        ...paceOfWork(work),
344      }
345    })
346  // 서브에이전트의 기록은 부모 업무 아래에 붙는다. 부모가 없으면 그 서브에이전트가 도는 동안만 따로 보인다.
347  const orphanRows: WorkRow[] = works.flatMap(work => {
348    const { agentId } = work
349    if (!agentId || parents.has(work.sessionId) || work.status !== 'active') return []
350    const agent = agentsOf(work.sessionId).find(item => item.id === agentId)
351    if (!agent || recordOf(agentId) !== work) return []
352    return [
353      {
354        work,
355        phase: 'running' as const,
356        children: [],
357        finished: [],
358        ...(agent.activity ? { activity: agent.activity } : {}),
359        ...paceOfWork(work),
360      },
361    ]
362  })
363
364  const rows = [...mainRows, ...orphanRows].sort(
365    (a, b) => PHASE_ORDER.indexOf(a.phase) - PHASE_ORDER.indexOf(b.phase) || b.work.updatedAt - a.work.updatedAt,
366  )
367  const tracked = new Set(
368    rows.filter(row => row.phase === 'running' || row.phase === 'yourTurn').map(row => row.work.sessionId),
369  )
370  const untracked = sessions.filter(s => !tracked.has(s.sessionId))
371  return { rows, untracked, at: now }
372}
373
374export const PHASE_LABEL: Record<Phase, string> = {
375  yourTurn: '내 차례',
376  running: '작업 중',
377  stalled: '멈춤',
378  paused: '보류',
379  followUp: '남은 일',
380  done: '오늘 완료',
381  dormant: '오래 멈춤',
382}
383
384export function countPhases(board: Board): { phase: Phase; count: number }[] {
385  return PHASE_ORDER.map(phase => ({ phase, count: board.rows.filter(row => row.phase === phase).length })).filter(
386    item => item.count > 0,
387  )
388}
389
390export function bar(percent: number, width: number): { filled: string; rest: string } {
391  const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width)
392  return { filled: '━'.repeat(filled), rest: '─'.repeat(width - filled) }
393}
394
395export function ago(at: number, now: number): string {
396  const minutes = Math.floor(Math.max(0, now - at) / 60_000)
397  if (minutes < 1) return '방금'
398  if (minutes < 60) return `${minutes}분 전`
399  const hours = Math.floor(minutes / 60)
400  if (hours < 24) return `${hours}시간 전`
401  return `${Math.floor(hours / 24)}일 전`
402}
403
404// 걸린 시간. 예: 40분, 1시간 20분, 2일
405export function span(ms: number): string {
406  const minutes = Math.max(1, Math.floor(ms / 60_000))
407  if (minutes < 60) return `${minutes}분`
408  const hours = Math.floor(minutes / 60)
409  if (hours >= 24) return `${Math.floor(hours / 24)}일`
410  const rest = minutes % 60
411  return rest > 0 ? `${hours}시간 ${rest}분` : `${hours}시간`
412}
413
414// 화면에 그릴 때 바뀌는 시각을 빼고 비교해, 내용이 같으면 다시 그리지 않는다.
415export function sameBoard(a: Board | null, b: Board): boolean {
416  if (!a) return false
417  return JSON.stringify({ ...a, at: 0 }) === JSON.stringify({ ...b, at: 0 })
418}
419
420export const TOOL_NAME = 'report'
421
422export const TOOL_DESCRIPTION =
423  '여러 단계짜리 업무의 목표, 전체 단계 목록, 지금 하는 일을 기록한다. 사용자는 이 기록으로 모든 세션의 진행도를 한 화면에서 본다. 매번 전체 단계 목록을 보낸다.'
424
425export const TOOL_SCHEMA = {
426  type: 'object',
427  properties: {
428    work_id: {
429      type: 'string',
430      description: '업무 식별자. 영문 소문자, 숫자, 하이픈. 이전 세션의 업무를 이어 가면 기존 id 를 그대로 쓴다.',
431    },
432    title: { type: 'string', description: '업무 목표 한 줄. 한국어 40자 안팎.' },
433    steps: {
434      type: 'array',
435      description: '전체 단계 목록. 끝난 단계도 빼지 않는다.',
436      items: {
437        type: 'object',
438        properties: {
439          title: { type: 'string', description: '단계 이름 한 줄' },
440          done: { type: 'boolean' },
441          evidence: { type: 'string', description: '완료 근거. 커밋 해시, 테스트 결과, 확인한 화면처럼 짧게.' },
442        },
443        required: ['title', 'done'],
444      },
445    },
446    now: { type: 'string', description: '지금 하는 일 한 줄' },
447    question: { type: 'string', description: '사용자 답을 기다리는 질문 한 줄. 없으면 생략.' },
448    waiting_for: { type: 'string', description: '보류한 이유나 기다리는 조건 한 줄. 예: 디자인 확정 뒤 구현' },
449    followups: {
450      type: 'array',
451      items: { type: 'string' },
452      description:
453        '완료한 뒤에도 이 업무 밖에서 할 일. 예: push, 배포 확인. 보내지 않으면 이전 목록을 그대로 두고, 다 끝냈으면 빈 목록을 보낸다.',
454    },
455    status: { type: 'string', enum: ['active', 'paused', 'done'], description: '기본은 active' },
456  },
457  required: ['work_id', 'title', 'steps'],
458}
459
460const GUIDE = `# 업무 진행 기록
461사용자는 여러 세션의 업무 진행도를 한 화면에서 본다. 그 화면은 \`mcp__work-progress__${TOOL_NAME}\` 기록으로 채워진다.
462- 3단계 이상이거나 여러 턴에 걸칠 작업을 시작하면 목표와 전체 단계 목록을 기록한다. 한 번에 끝나는 질문, 설명, 작은 수정에는 기록하지 않는다.
463- 단계를 끝냈을 때, 계획이 바뀌어 단계가 늘거나 줄 때, 사용자 답을 기다리며 턴을 끝내기 직전, 업무가 끝났을 때 다시 기록한다.
464- 단계 완료는 근거가 있을 때만 표시하고 evidence 에 짧게 적는다.
465- 보류로 기록할 때는 기다리는 이유를 waiting_for 에 적는다.
466- 완료로 닫을 때 push, 배포 확인처럼 이 업무 밖에서 할 일이 남으면 followups 에 적는다. 남은 일을 끝내면 그 일을 뺀 목록으로 다시 기록한다.
467- 이전 세션에서 하던 업무를 이어 가면 아래 목록의 work_id 를 그대로 쓴다.
468- 서브에이전트로 일할 때는 부모 대화의 work_id 를 쓰지 않고 맡은 일만 새 work_id 로 기록한다. 부모 업무 아래 한 줄로 보인다.`
469
470// 시스템 안내가 바뀌면 대화 전체의 prompt cache 가 깨지므로 세션 시작 때 한 번만 만든다.
471export function guideText(board: Board | null): string {
472  const rows = (board?.rows ?? []).filter(row => !isSubWork(row.work))
473  const open = rows.filter(row => row.phase !== 'done' && row.phase !== 'followUp')
474  const left = rows.filter(row => row.phase === 'followUp')
475  const openText =
476    open.length === 0
477      ? '진행 중인 업무: 없음'
478      : `세션 시작 때 진행 중이던 업무:\n${open
479          .map(({ work }) => {
480            const { done, total } = progressOf(work)
481            return `- ${work.id} · ${work.title} · ${done}/${total} 단계 · @${work.sessionName} · ${ago(work.updatedAt, board?.at ?? work.updatedAt)}`
482          })
483          .join('\n')}`
484  const leftText =
485    left.length === 0
486      ? ''
487      : `\n\n남은 일이 있는 완료 업무:\n${left
488          .map(({ work }) => `- ${work.id} · ${work.title} · 남은 일: ${(work.followups ?? []).join(', ')}`)
489          .join('\n')}`
490  return `${GUIDE}\n\n${openText}${leftText}`
491}
492
hooks/summary.ts 46 lines
1import type { Work } from '../types'
2import { isSubWork, progressOf } from './model'
3
4const DAY_MS = 24 * 60 * 60_000
5
6// /progress 뒤에 이 말을 붙이면 패널 대신 하루 요약을 글로 낸다.
7export const SUMMARY_ARGS = ['요약', '오늘', 'today', 'summary']
8
9function doneLines(work: Work, works: Work[]): string[] {
10  const subs = works.filter(record => record.parentId === work.id).length
11  const head = `- ${work.title} · ${work.steps.length}단계${subs > 0 ? ` · 서브 ${subs}` : ''}`
12  const proofs = work.steps.filter(step => step.isDone && step.evidence).map(step => `    ✓ ${step.title} — ${step.evidence}`)
13  return [head, ...proofs]
14}
15
16function openLine(work: Work): string {
17  const { done, total } = progressOf(work)
18  const state = work.status === 'paused' ? ' · 보류' : ''
19  const reason = work.waitingFor ? ` · 기다림: ${work.waitingFor}` : work.now ? ` · 지금: ${work.now}` : ''
20  return `- ${work.title} · ${done}/${total}${state}${reason}`
21}
22
23// 최근 24시간 동안 메인 대화가 끝낸 업무, 아직 하는 업무, 완료 뒤 남은 일을 글로 묶는다.
24// 서브에이전트 기록은 부모 업무의 서브 개수로만 센다.
25export function dailySummary(works: Work[], now: number): string {
26  const main = works.filter(work => !isSubWork(work))
27  const isRecent = (at: number) => now - at < DAY_MS
28  const finished = main
29    .filter(work => work.doneAt !== undefined && isRecent(work.doneAt) && (work.status === 'done' || work.status === 'archived'))
30    .sort((a, b) => (a.doneAt ?? 0) - (b.doneAt ?? 0))
31  const open = main
32    .filter(work => (work.status === 'active' || work.status === 'paused') && isRecent(work.updatedAt))
33    .sort((a, b) => b.updatedAt - a.updatedAt)
34  const left = main.filter(work => work.status === 'done' && work.followups)
35
36  if (finished.length + open.length + left.length === 0) return '최근 24시간에 기록된 업무가 없어요.'
37  const sections = [
38    finished.length > 0 ? [`완료 ${finished.length}`, ...finished.flatMap(work => doneLines(work, works))] : [],
39    open.length > 0 ? [`진행 중 ${open.length}`, ...open.map(openLine)] : [],
40    left.length > 0
41      ? [`남은 일 ${left.length}`, ...left.map(work => `- ${work.title}: ${(work.followups ?? []).join(', ')}`)]
42      : [],
43  ].filter(lines => lines.length > 0)
44  return ['업무 요약 · 최근 24시간', ...sections.map(lines => lines.join('\n'))].join('\n\n')
45}
46
hooks/view.tsx 486 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { Board, ChildRow, Phase, Work, WorkRow } from '../types'
4import { PHASE_LABEL, PHASE_ORDER, ago, bar, countPhases, progressOf, span, stepSpans, stepStartedAt } from './model'
5import { remainingMs } from './pace'
6
7type Els = Elements[RenderSurface]
8
9// wide 는 모든 글자, narrow 는 부가 정보를 빼고, tiny 는 아이콘과 숫자만 남긴다.
10export type Density = 'wide' | 'narrow' | 'tiny'
11
12const PHASE_ICON: Record<Phase, string> = {
13  yourTurn: '◆',
14  running: '●',
15  stalled: '○',
16  paused: '‖',
17  followUp: '□',
18  done: '✓',
19  dormant: '◌',
20}
21const PHASE_COLOR: Record<Phase, string> = {
22  yourTurn: 'warning',
23  running: 'suggestion',
24  stalled: 'subtle',
25  paused: 'subtle',
26  followUp: 'warning',
27  done: 'success',
28  dormant: 'subtle',
29}
30// 이 칸들은 업무를 제목 한 줄로 접어 두고, 누른 업무만 펼친다.
31const FOLDED: Phase[] = ['followUp', 'done', 'dormant']
32const BAR_WIDTH: Record<Density, number> = { wide: 10, narrow: 6, tiny: 4 }
33// 펼침 표시 한 칸과 공백 한 칸만큼 들여 써서 둘째 줄부터 제목과 줄을 맞춘다.
34const INDENT = 2
35const MAX_CHILDREN = 4
36// 지금 단계가 이보다 오래 걸리면 막대 옆에 알린다.
37const SLOW_STEP_MS = 30 * 60_000
38const MINUTE_MS = 60_000
39const HOUR_MS = 60 * MINUTE_MS
40const MARK = { open: '▾', closed: '▸' }
41// 덧붙이는 정보의 글자색. dimColor 는 어두운 배경에서 거의 안 보여서, 구분선 말고는 테마의 옅은 회색을 쓴다.
42const SOFT = 'subtle'
43
44export function densityOf(columns: number): Density {
45  if (columns >= 60) return 'wide'
46  if (columns >= 40) return 'narrow'
47  return 'tiny'
48}
49
50// 한글과 전각 문자는 터미널에서 두 칸을 차지한다.
51function cells(ch: string): number {
52  const code = ch.codePointAt(0) ?? 0
53  const isWide =
54    (code >= 0x1100 && code <= 0x115f) ||
55    (code >= 0x2e80 && code <= 0xa4cf) ||
56    (code >= 0xac00 && code <= 0xd7a3) ||
57    (code >= 0xf900 && code <= 0xfaff) ||
58    (code >= 0xff00 && code <= 0xff60) ||
59    (code >= 0xffe0 && code <= 0xffe6)
60  return isWide ? 2 : 1
61}
62
63export function widthOf(text: string): number {
64  return [...text].reduce((sum, ch) => sum + cells(ch), 0)
65}
66
67export function fit(text: string, width: number): string {
68  const chars = [...text]
69  if (widthOf(text) <= width) return text
70  let used = 0
71  let out = ''
72  for (const ch of chars) {
73    if (used + cells(ch) > width - 1) break
74    used += cells(ch)
75    out += ch
76  }
77  return `${out}…`
78}
79
80// 칸마다 위에 긋는 선. 폭에 맞춰 늘이고 앞쪽에 칸 이름을 넣는다.
81export function divider(label: string, columns: number): string {
82  const head = `── ${label} `
83  return `${head}${'─'.repeat(Math.max(2, columns - widthOf(head)))}`
84}
85
86function phaseCount(phase: Phase, count: number, density: Density): string {
87  return density === 'tiny' ? `${PHASE_ICON[phase]}${count}` : `${PHASE_ICON[phase]} ${PHASE_LABEL[phase]} ${count}`
88}
89
90function isLeftOpen(child: ChildRow): boolean {
91  return child.status === 'active' || child.status === 'paused'
92}
93
94export function drawSummary(els: Els, board: Board, density: Density, hasHint: boolean) {
95  const { Text } = els
96  const counts = countPhases(board)
97  const total = density === 'wide' ? `업무 ${board.rows.length}개` : `업무 ${board.rows.length}`
98  const untracked = density === 'wide' && board.untracked.length > 0 ? `기록 없는 세션 ${board.untracked.length}` : ''
99  return (
100    <Text wrap="truncate-end">
101      <Text bold>{total}</Text>
102      {counts.map(({ phase, count }) => (
103        <Text key={`count-${phase}`}>
104          <Text dimColor>{' · '}</Text>
105          <Text color={PHASE_COLOR[phase]}>{phaseCount(phase, count, density)}</Text>
106        </Text>
107      ))}
108      {untracked ? <Text color={SOFT}>{` · ${untracked}`}</Text> : ''}
109      {hasHint && density !== 'tiny' ? <Text color={SOFT}>{'  /progress'}</Text> : ''}
110    </Text>
111  )
112}
113
114function drawHeader(els: Els, phase: Phase, count: number, columns: number) {
115  const { Text } = els
116  const label = phaseCount(phase, count, densityOf(columns))
117  const rule = divider(label, columns).slice(`── ${label} `.length)
118  return (
119    <Text wrap="truncate-end" key={`head-${phase}`}>
120      <Text dimColor>{'── '}</Text>
121      <Text color={PHASE_COLOR[phase]} bold>
122        {label}
123      </Text>
124      <Text dimColor>{` ${rule}`}</Text>
125    </Text>
126  )
127}
128
129// 서브에이전트가 완료로 닫지 않고 끝난 기록. 부모 업무를 접어 두어도 보인다.
130function drawUnclosed(els: Els, unclosed: ChildRow[]) {
131  const { Text } = els
132  return unclosed.map(child => (
133    <Text wrap="truncate-end" key={`open-${child.id}`}>
134      <Text color={SOFT}>{'└ '}</Text>
135      <Text color="warning">{child.status === 'paused' ? PHASE_ICON.paused : PHASE_ICON.stalled}</Text>
136      {` ${child.title}`}
137      {child.total ? ` ${child.done}/${child.total}` : ''}
138      <Text color="warning">{child.status === 'paused' ? ' · 보류로 끝남' : ' · 닫지 않고 끝남'}</Text>
139      {child.now ? <Text color={SOFT}>{` · ${child.now}`}</Text> : ''}
140    </Text>
141  ))
142}
143
144// 부모 업무 아래 서브에이전트 줄. 끝난 서브에이전트는 한 줄로 묶었다가 업무를 펼치면 하나씩 보인다.
145function drawChildren(els: Els, row: WorkRow, isOpen: boolean, density: Density) {
146  const { Box, Text } = els
147  const { children, finished } = row
148  if (children.length === 0 && finished.length === 0) return ''
149  const unclosed = finished.filter(isLeftOpen)
150  const closed = finished.filter(child => !isLeftOpen(child))
151  if (density === 'tiny') {
152    const parts = [
153      ...(children.length > 0 ? [`서브 ${children.length}`] : []),
154      ...(closed.length > 0 ? [`${PHASE_ICON.done}${closed.length}`] : []),
155      ...(unclosed.length > 0 ? [`안 닫힘 ${unclosed.length}`] : []),
156    ]
157    return (
158      <Box marginLeft={INDENT}>
159        <Text color={SOFT}>{`└ ${parts.join(' ')}`}</Text>
160      </Box>
161    )
162  }
163  const hidden = children.length - MAX_CHILDREN
164  return (
165    <Box marginLeft={INDENT} flexDirection="column">
166      {children.slice(0, MAX_CHILDREN).map(child => (
167        <Text wrap="truncate-end" key={`child-${child.id}`}>
168          <Text color={SOFT}>{'└ '}</Text>
169          <Text color={PHASE_COLOR.running}>{PHASE_ICON.running}</Text>
170          {` ${child.title}`}
171          {child.total ? <Text bold>{` ${child.done}/${child.total}`}</Text> : ''}
172          {child.activity ? <Text color={SOFT}>{` · 최근: ${child.activity.text}`}</Text> : ''}
173        </Text>
174      ))}
175      {hidden > 0 ? <Text color={SOFT}>{`  서브에이전트 ${hidden}개 더`}</Text> : ''}
176      {drawUnclosed(els, unclosed)}
177      {isOpen ? (
178        closed.map(child => (
179          <Text color={SOFT} wrap="truncate-end" key={`finished-${child.id}`}>
180            {'└ '}
181            <Text color={PHASE_COLOR.done}>{PHASE_ICON.done}</Text>
182            {` ${child.title}`}
183            {child.total ? ` ${child.done}/${child.total}` : ''}
184          </Text>
185        ))
186      ) : closed.length > 0 ? (
187        <Text color={SOFT}>
188          {'└ '}
189          <Text color={PHASE_COLOR.done}>{PHASE_ICON.done}</Text>
190          {` 서브에이전트 ${closed.length}개 끝남`}
191        </Text>
192      ) : (
193        ''
194      )}
195    </Box>
196  )
197}
198
199function drawFollowups(els: Els, work: Work) {
200  const { Box, Text } = els
201  if (!work.followups) return ''
202  return (
203    <Box marginLeft={INDENT} flexDirection="column">
204      {work.followups.map((item, index) => (
205        <Text wrap="truncate-end" key={`followup-${index}`}>
206          <Text color={PHASE_COLOR.followUp}>{PHASE_ICON.followUp}</Text>
207          {` ${item}`}
208        </Text>
209      ))}
210    </Box>
211  )
212}
213
214type StepKind = 'done' | 'current' | 'todo'
215
216// 끝낸 단계 ●, 지금 단계 ◉, 남은 단계 ○. 완료한 업무에는 지금 단계가 없다.
217function stepKinds(work: Work, phase: Phase): StepKind[] {
218  const current = phase === 'done' || phase === 'followUp' ? -1 : work.steps.findIndex(step => !step.isDone)
219  return work.steps.map((step, index) => (step.isDone ? 'done' : index === current ? 'current' : 'todo'))
220}
221
222const NODE: Record<StepKind, string> = { done: '●', current: '◉', todo: '○' }
223
224// 끝낸 단계에서 끝낸 단계나 지금 단계로 가는 구간만 지나온 길로 친다. 순서를 건너뛰어 끝낸 단계 뒤는 가는 선이다.
225function isWalked(kinds: StepKind[], index: number): boolean {
226  return kinds[index] === 'done' && kinds[index + 1] !== undefined && kinds[index + 1] !== 'todo'
227}
228
229// 단계를 노드로 이은 한 줄. 지나온 구간은 굵은 선, 남은 구간은 가는 선으로 잇는다.
230// 자리가 모자라면 선을 빼고 노드만, 그래도 모자라면 막대로 그린다.
231function drawChain(els: Els, work: Work, phase: Phase, room: number, density: Density) {
232  const { Text } = els
233  const kinds = stepKinds(work, phase)
234  const total = kinds.length
235  const isJoined = total * 2 - 1 <= room
236  if (total === 0 || (!isJoined && total > room)) {
237    const meter = bar(progressOf(work).percent, BAR_WIDTH[density])
238    return [
239      <Text color={PHASE_COLOR[phase]} key="bar-filled">
240        {meter.filled}
241      </Text>,
242      <Text color={SOFT} key="bar-rest">
243        {meter.rest}
244      </Text>,
245    ]
246  }
247  return kinds.map((kind, index) => {
248    const walked = isWalked(kinds, index)
249    const link = isJoined && index < total - 1 ? (walked ? '━' : '─') : ''
250    return (
251      <Text key={`node-${index}`}>
252        <Text color={kind === 'todo' ? SOFT : PHASE_COLOR[phase]} bold={kind === 'current'}>
253          {NODE[kind]}
254        </Text>
255        {link ? (
256          <Text color={walked ? PHASE_COLOR[phase] : SOFT}>
257            {link}
258          </Text>
259        ) : (
260          ''
261        )}
262      </Text>
263    )
264  })
265}
266
267// 펼친 업무의 단계를 위에서 아래로 흐르는 그림으로 그린다. 노드 사이를 세로선으로 잇는다.
268function drawSteps(els: Els, work: Work, phase: Phase) {
269  const { Text } = els
270  const spans = stepSpans(work)
271  const kinds = stepKinds(work, phase)
272  return work.steps.flatMap((step, index) => {
273    const kind = kinds[index] ?? 'todo'
274    const took = spans[index]
275    const node = (
276      <Text color={kind === 'todo' ? SOFT : undefined} bold={kind === 'current'} wrap="truncate-end" key={`step-${index}`}>
277        <Text color={kind === 'todo' ? SOFT : PHASE_COLOR[phase]}>{NODE[kind]}</Text>
278        {` ${step.title}`}
279        {took ? <Text color={SOFT}>{` · ${span(took)}`}</Text> : ''}
280        {step.evidence ? <Text color={SOFT}>{` — ${step.evidence}`}</Text> : ''}
281        {kind === 'current' ? <Text color={PHASE_COLOR[phase]}>{'  ← 지금'}</Text> : ''}
282      </Text>
283    )
284    if (index === work.steps.length - 1) return [node]
285    const walked = isWalked(kinds, index)
286    const link = (
287      <Text color={walked ? PHASE_COLOR[phase] : SOFT} key={`link-${index}`}>
288        {walked ? '┃' : '│'}
289      </Text>
290    )
291    return [node, link]
292  })
293}
294
295// 업무 줄 아래 한 줄. 사용자 답을 기다리면 질문, 보류면 기다리는 이유, 아니면 지금 하는 일이나 다음 단계를 보인다.
296function detailOf(row: WorkRow): { label: string; text: string; isAsk: boolean } | undefined {
297  const { work, phase } = row
298  if (phase === 'yourTurn' && work.question) return { label: '질문: ', text: work.question, isAsk: true }
299  if (work.waitingFor) return { label: '기다림: ', text: work.waitingFor, isAsk: false }
300  if (work.now) return { label: '지금: ', text: work.now, isAsk: false }
301  const next = work.steps.find(step => !step.isDone)
302  return next && phase !== 'done' && phase !== 'followUp' ? { label: '다음 단계: ', text: next.title, isAsk: false } : undefined
303}
304
305// 이름표는 옅은 회색, 내용은 본문 색으로 쓴다.
306function drawLabeled(els: Els, label: string, text: string, tail = '') {
307  const { Text } = els
308  return (
309    <Text wrap="truncate-end">
310      <Text color={SOFT}>{label}</Text>
311      {text}
312      {tail ? <Text color={SOFT}>{tail}</Text> : ''}
313    </Text>
314  )
315}
316
317// 지금 단계가 오래 걸리면 "이 단계 1시간째"를 돌려준다. 누가 일하는 중일 때만 센다.
318function slowStep(row: WorkRow, now: number): string {
319  const { work, phase } = row
320  if (phase !== 'running' && phase !== 'yourTurn') return ''
321  if (work.steps.every(step => step.isDone)) return ''
322  const started = stepStartedAt(work)
323  if (started === undefined || now - started < SLOW_STEP_MS) return ''
324  return `이 단계 ${span(now - started)}째`
325}
326
327// 남은 단계와 단계 하나에 걸리는 시간으로 본 예상. 예상이라 10분이 안 되면 분, 1시간이 안 되면 5분, 그 위는 10분 단위로 끊는다.
328function etaText(row: WorkRow, now: number): string {
329  if (row.paceMs === undefined) return ''
330  const ms = remainingMs(row.work, row.paceMs, row.phase, now)
331  if (ms === undefined) return ''
332  const unit = ms < 10 * MINUTE_MS ? MINUTE_MS : ms < HOUR_MS ? 5 * MINUTE_MS : 10 * MINUTE_MS
333  return `약 ${span(Math.round(ms / unit) * unit)} 남음`
334}
335
336type RowActions = { onToggle: (id: string) => void; onArchive: (id: string) => void }
337
338// 완료·남은 일·오래 멈춤 칸의 업무는 제목 한 줄로 접는다. 남은 일과 닫히지 않은 서브 기록은 접어도 보인다.
339function drawFolded(els: Els, row: WorkRow, now: number, columns: number, actions: RowActions) {
340  const { Box, Button, Text } = els
341  const { work, phase } = row
342  const density = densityOf(columns)
343  const unclosed = row.finished.filter(isLeftOpen)
344  const { done, total } = progressOf(work)
345  const parts = [
346    ...(phase === 'dormant' ? [`${done}/${total}`] : []),
347    ...(row.finished.length > 0 ? [`서브 ${row.finished.length}`] : []),
348    ago(work.doneAt ?? work.updatedAt, now),
349  ]
350  const tail = density === 'tiny' ? '' : ` · ${parts.join(' · ')}`
351  const title = fit(work.title, Math.max(4, columns - INDENT - widthOf(tail)))
352  return (
353    <Box flexDirection="column" key={`row-${work.id}`}>
354      <Button key={`toggle-${work.id}`} plain onPress={() => actions.onToggle(work.id)}>
355        <Text wrap="truncate-end">
356          <Text color={SOFT}>{MARK.closed}</Text> <Text color={phase === 'followUp' ? undefined : SOFT}>{title}</Text>
357          <Text color={SOFT}>{tail}</Text>
358        </Text>
359      </Button>
360      {phase === 'followUp' ? drawFollowups(els, work) : ''}
361      {phase === 'dormant' && work.waitingFor && density !== 'tiny' ? (
362        <Box marginLeft={INDENT}>
363          {drawLabeled(els, '기다림: ', work.waitingFor)}
364        </Box>
365      ) : (
366        ''
367      )}
368      {unclosed.length > 0 && density !== 'tiny' ? (
369        <Box marginLeft={INDENT} flexDirection="column">
370          {drawUnclosed(els, unclosed)}
371        </Box>
372      ) : (
373        ''
374      )}
375    </Box>
376  )
377}
378
379function drawRow(els: Els, row: WorkRow, isOpen: boolean, now: number, columns: number, actions: RowActions) {
380  const { Box, Button, Text } = els
381  const { work, phase } = row
382  if (FOLDED.includes(phase) && !isOpen) return drawFolded(els, row, now, columns, actions)
383  const density = densityOf(columns)
384  const { done, total } = progressOf(work)
385  const title = fit(work.title, Math.max(4, columns - INDENT))
386  const slow = density === 'tiny' ? '' : slowStep(row, now)
387  const eta = density === 'tiny' ? '' : etaText(row, now)
388  const detail = detailOf(row)
389  const where = density === 'tiny' ? ago(work.updatedAt, now) : `${ago(work.updatedAt, now)} · @${work.sessionName}`
390  // 노드 줄은 개수, 예상 시간, 오래 걸림 알림, 시각과 세션 이름을 뺀 자리에 맞춘다.
391  const room =
392    columns - INDENT - widthOf(` ${done}/${total}${eta ? ` · ${eta}` : ''}${slow ? ` · ${slow}` : ''} · ${where}`)
393
394  return (
395    <Box flexDirection="column" key={`row-${work.id}`}>
396      <Button key={`toggle-${work.id}`} plain onPress={() => actions.onToggle(work.id)}>
397        <Text wrap="truncate-end">
398          <Text color={SOFT}>{isOpen ? MARK.open : MARK.closed}</Text> <Text bold>{title}</Text>
399        </Text>
400      </Button>
401      <Box marginLeft={INDENT}>
402        <Text wrap="truncate-end">
403          {drawChain(els, work, phase, room, density)}
404          <Text bold>{` ${done}/${total}`}</Text>
405          {eta ? <Text>{` · ${eta}`}</Text> : ''}
406          {slow ? <Text color="warning">{` · ${slow}`}</Text> : ''}
407          <Text color={SOFT}>{` · ${where}`}</Text>
408        </Text>
409      </Box>
410      {detail ? (
411        <Box marginLeft={INDENT}>
412          {detail.isAsk ? (
413            <Text color="warning" wrap="truncate-end">{`${detail.label}${detail.text}`}</Text>
414          ) : (
415            drawLabeled(els, detail.label, detail.text)
416          )}
417        </Box>
418      ) : (
419        ''
420      )}
421      {row.activity && density !== 'tiny' ? (
422        <Box marginLeft={INDENT}>
423          {drawLabeled(els, '최근: ', row.activity.text, ` · ${ago(row.activity.at, now)}`)}
424        </Box>
425      ) : (
426        ''
427      )}
428      {drawChildren(els, row, isOpen, density)}
429      {isOpen ? (
430        <Box marginLeft={INDENT} flexDirection="column">
431          {drawSteps(els, work, phase)}
432        </Box>
433      ) : (
434        ''
435      )}
436      {isOpen ? drawFollowups(els, work) : ''}
437      {isOpen ? (
438        <Box marginLeft={INDENT}>
439          <Button key={`archive-${work.id}`} label="보관" onPress={() => actions.onArchive(work.id)} />
440        </Box>
441      ) : (
442        ''
443      )}
444    </Box>
445  )
446}
447
448// 할 일이 남은 칸을 위에, 접어 두는 칸을 아래에 둔다. 칸마다 이름과 개수를 적은 선을 긋는다.
449export function drawPane(els: Els, board: Board | null, expanded: string | null, columns: number, actions: RowActions) {
450  const { Box, Text } = els
451  const density = densityOf(columns)
452  if (!board || (board.rows.length === 0 && board.untracked.length === 0)) {
453    return <Text color={SOFT}>기록된 업무가 없어요.</Text>
454  }
455
456  const draw = (row: WorkRow) => drawRow(els, row, expanded === row.work.id, board.at, columns, actions)
457  const groups = PHASE_ORDER.map(phase => ({ phase, rows: board.rows.filter(row => row.phase === phase) })).filter(
458    group => group.rows.length > 0,
459  )
460  const open = groups.filter(group => !FOLDED.includes(group.phase))
461  const folded = groups.filter(group => FOLDED.includes(group.phase))
462  const untracked = board.untracked.map(s => `@${s.name}${s.isBusy ? ' 작업 중' : ''}`).join(', ')
463  return (
464    <Box flexDirection="column" rowGap={1}>
465      {drawSummary(els, board, density, false)}
466      {open.length === 0 ? <Text color={SOFT}>진행 중인 업무가 없어요.</Text> : ''}
467      {open.map(({ phase, rows }) => (
468        <Box flexDirection="column" key={`group-${phase}`}>
469          {drawHeader(els, phase, rows.length, columns)}
470          <Box flexDirection="column" rowGap={1}>
471            {rows.map(draw)}
472          </Box>
473        </Box>
474      ))}
475      {untracked && density !== 'tiny' ? <Text color={SOFT} wrap="truncate-end">{`기록 없는 세션: ${untracked}`}</Text> : ''}
476      {folded.map(({ phase, rows }) => (
477        <Box flexDirection="column" key={`group-${phase}`}>
478          {drawHeader(els, phase, rows.length, columns)}
479          {rows.map(draw)}
480        </Box>
481      ))}
482      {density === 'wide' ? <Text color={SOFT}>업무 줄을 누르면 단계 목록이 펼쳐져요.</Text> : ''}
483    </Box>
484  )
485}
486
hooks/pace.ts 69 lines
1import type { Phase, Work } from '../types'
2
3const MINUTE_MS = 60_000
4// 이 업무를 이만큼 쟀으면 이 업무의 속도만 본다. 그보다 적으면 지난 업무 기록과 섞는다.
5const OWN_SAMPLES = 2
6// 섞어도 이보다 적으면 예상하지 않는다.
7const MIN_SAMPLES = 3
8// 지난 기록은 최근에 기록한 업무만 본다. 일하는 방식이 바뀌면 오래된 기록은 맞지 않는다.
9const HISTORY_WORKS = 40
10
11// 지금 단계를 시작한 시각. 예전 기록이라 완료 시각을 모르면 undefined 다.
12export function stepStartedAt(work: Work): number | undefined {
13  const done = work.steps.filter(step => step.isDone)
14  if (done.length === 0) return work.createdAt
15  const times = done.flatMap(step => (step.doneAt === undefined ? [] : [step.doneAt]))
16  return times.length > 0 ? Math.max(...times) : undefined
17}
18
19// 단계마다 걸린 시간. 끝낸 순서대로 앞 단계를 끝낸 시각부터 잰다. 1분이 안 되거나 모르면 undefined 다.
20export function stepSpans(work: Work): (number | undefined)[] {
21  const order = work.steps
22    .map((step, index) => ({ index, at: step.doneAt }))
23    .filter((item): item is { index: number; at: number } => item.at !== undefined)
24    .sort((a, b) => a.at - b.at)
25  const spans: (number | undefined)[] = work.steps.map(() => undefined)
26  let from = work.createdAt
27  for (const { index, at } of order) {
28    spans[index] = at - from >= MINUTE_MS ? at - from : undefined
29    from = at
30  }
31  return spans
32}
33
34function measured(work: Work): number[] {
35  return stepSpans(work).filter((ms): ms is number => ms !== undefined)
36}
37
38// 자리를 비운 시간처럼 튀는 값에 덜 흔들리도록 평균 대신 중앙값을 쓴다.
39function median(values: number[]): number {
40  const sorted = [...values].sort((a, b) => a - b)
41  const mid = Math.floor(sorted.length / 2)
42  const upper = sorted[mid] ?? 0
43  return sorted.length % 2 === 1 ? upper : ((sorted[mid - 1] ?? upper) + upper) / 2
44}
45
46// 단계 하나에 걸리는 시간. others 는 같은 종류의 다른 업무다. 서브에이전트와 메인 대화는 속도가 달라 섞지 않는다.
47export function paceOf(work: Work, others: Work[]): number | undefined {
48  const own = measured(work)
49  if (own.length >= OWN_SAMPLES) return median(own)
50  const history = others
51    .filter(other => other.id !== work.id)
52    .sort((a, b) => b.updatedAt - a.updatedAt)
53    .slice(0, HISTORY_WORKS)
54    .flatMap(measured)
55  const pool = [...own, ...history]
56  return pool.length >= MIN_SAMPLES ? median(pool) : undefined
57}
58
59// 남은 단계를 마치는 데 걸릴 시간. 일하는 중이면 지금 단계에 이미 쓴 시간을 뺀다.
60// 남은 단계가 없거나 예상한 시간을 이미 넘겼으면 undefined 다.
61export function remainingMs(work: Work, paceMs: number, phase: Phase, now: number): number | undefined {
62  const left = work.steps.filter(step => !step.isDone).length
63  if (left === 0) return undefined
64  const started = phase === 'running' || phase === 'yourTurn' ? stepStartedAt(work) : undefined
65  const spent = started === undefined ? 0 : Math.min(Math.max(0, now - started), paceMs)
66  const ms = left * paceMs - spent
67  return ms >= MINUTE_MS ? ms : undefined
68}
69
types/index.d.ts 79 lines
1// doneAt 은 그 단계를 처음 완료로 기록한 시각이다. 단계마다 걸린 시간을 여기서 계산한다.
2export type Step = { title: string; isDone: boolean; evidence?: string; doneAt?: number }
3
4export type WorkStatus = 'active' | 'paused' | 'done' | 'archived'
5
6// 업무 하나가 파일 하나다. 여러 세션이 이어 쓰므로 마지막으로 기록한 세션만 남긴다.
7export type Work = {
8  id: string
9  title: string
10  steps: Step[]
11  now?: string
12  question?: string
13  // 보류한 이유나 기다리는 조건. 예: 디자인 확정 뒤 구현
14  waitingFor?: string
15  // 완료한 뒤에도 이 업무 밖에서 해야 할 일. 예: push, 배포 확인
16  followups?: string[]
17  status: WorkStatus
18  sessionId: string
19  sessionName: string
20  // 서브에이전트가 기록했으면 그 서브에이전트 id. 메인 대화의 기록에는 없다.
21  agentId?: string
22  // 서브에이전트의 기록이면, 기록할 때 그 세션이 하던 메인 업무의 id. 끝난 뒤에도 부모 아래에 묶는다.
23  parentId?: string
24  createdAt: number
25  updatedAt: number
26  doneAt?: number
27}
28
29export type LiveSession = { sessionId: string; name: string; isBusy: boolean }
30
31// 마지막으로 쓴 도구를 사람이 읽는 한 줄로 바꾼 것
32export type Activity = { text: string; at: number }
33
34export type LiveAgent = { id: string; description: string; activity?: Activity }
35
36// 세션이 10초마다 ~/.claude/work-progress/live 에 남기는 상태. 다른 세션은 이 파일로만 안다.
37export type SessionLive = { activity?: Activity; agents: LiveAgent[] }
38
39// 부모 업무 아래 한 줄. 서브에이전트가 기록을 남겼으면 그 진행도를 붙인다.
40// 끝난 서브에이전트의 줄에는 그 기록의 상태를 붙인다. done 이 아니면 기록을 닫지 않고 끝난 것이다.
41export type ChildRow = {
42  id: string
43  title: string
44  done?: number
45  total?: number
46  activity?: Activity
47  status?: WorkStatus
48  now?: string
49}
50
51// running: 세션이 일하는 중, yourTurn: 세션이 사용자 입력을 기다림, stalled: 세션이 없거나 다른 업무로 넘어감
52// followUp: 완료했지만 남은 일이 있음, dormant: 멈춤이나 보류인 채 하루 넘게 기록이 없음
53export type Phase = 'yourTurn' | 'running' | 'stalled' | 'paused' | 'followUp' | 'done' | 'dormant'
54
55// children 은 도는 서브에이전트, finished 는 이 업무 아래 끝난 서브에이전트 기록
56// paceMs 는 이 업무와 지난 업무 기록으로 본 단계 하나에 걸리는 시간이다. 기록이 모자라면 없다.
57export type WorkRow = {
58  work: Work
59  phase: Phase
60  children: ChildRow[]
61  finished: ChildRow[]
62  activity?: Activity
63  paceMs?: number
64}
65
66export type Board = { rows: WorkRow[]; untracked: LiveSession[]; at: number }
67
68declare module 'claude-code' {
69  interface PluginState {
70    'work-progress': {
71      board: Board | null
72      expanded: string | null
73      snapshot: string | null
74      live: string | null
75      activity: Record<string, Activity> | null
76    }
77  }
78}
79