SLOPSHOPPER

work-summary

파일을 바꾼 턴이 끝나면 오른쪽 패널에 바꾼 파일·한 일·배울 점을 짧게 정리합니다

newpaneguardcommandmodeltimer
v0.2.2no licenseupdated 2026-10-06timinguniq/claude-work-summary
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-summary
│ ┃ 작업 요약 ✕ › 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 │ │ › /work-summary │ ⎿ work-summary: 작업 요약 패널을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 작업 요약
파일을 바꾼 턴이 끝나면 여기에 요약이 쌓입니다.
README

work-summary

Claude Code mod. 파일을 바꾼 턴이 끝나면 오른쪽 패널에 바꾼 파일 · 한 일 · 배울 점을 짧게 정리합니다.

1: 바꾼 파일  2: 한 일  3: 배울 점

▸ 홈 버튼 고쳐줘
  • 홈 버튼 색을 design-guide 토큰으로 교체

설치

터미널의 Claude Code 프롬프트에서:

/plugin marketplace add timinguniq/claude-work-summary
/plugin install work-summary@claude-work-summary

또는 셸에서:

claude plugin marketplace add timinguniq/claude-work-summary
claude plugin install work-summary@claude-work-summary

user 범위로 설치되어 이후 새 세션에서 켜집니다. 이미 열려 있는 세션에서는 /reload-plugins로 불러옵니다.

업데이트: claude plugin update work-summary 후 /reload-plugins.

동작

  • 패널 위의 탭(바꾼 파일 · 한 일 · 배울 점)을 누르면 모든 항목에서 그 부분만 보입니다. 처음에는 바꾼 파일 탭이 열립니다.
  • 바꾼 파일: Edit · Write · NotebookEdit 도구로 바꾼 파일. 서브에이전트가 바꾼 것도 포함하고, 실패하거나 거부된 수정은 뺍니다. 턴이 끝나자마자 보입니다.
  • 한 일 · 배울 점: 턴이 끝난 뒤 세션 대화를 같은 모델로 한 번 더 보내(fork) 만듭니다. 그동안은 "요약 중…"이 보입니다.
  • 패널은 세션을 시작할 때 열리고, 터미널 폭이 144칸보다 좁으면 기다립니다. /work-summary로 직접 열면 폭과 상관없이 뜹니다.
  • 최근 20개 항목을 세션 안에서만 보관합니다.

단축키

키동작
ctrl+x 다음 tab패널로 키보드를 옮깁니다
1 2 3바꾼 파일 · 한 일 · 배울 점 탭으로 바로 바꿉니다 (패널에 키보드가 있을 때)
Tab탭(바꾼 파일 · 한 일 · 배울 점) 사이를 옮깁니다
Enter고른 탭을 엽니다
↑ ↓패널을 스크롤합니다
Esc프롬프트로 돌아갑니다
ctrl+x 다음 x패널을 닫습니다 (/work-summary로 다시 엽니다)

주의

  • 파일을 바꾼 턴마다 모델 호출이 한 번 더 일어납니다. 대화 앞부분은 프롬프트 캐시로 처리되지만 비용이 듭니다.
  • Claude Code의 function hook mod API는 early access라 버전에 따라 바뀔 수 있습니다. Claude Code 2.1.290에서 만들고 테스트했습니다.

개발

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 180 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
3
4import type { SummaryTab, WorkSummary } from '../types'
5
6const PANE = 'work-summary'
7const TITLE = '작업 요약'
8const OPEN = { id: PANE, title: TITLE, columns: 40 }
9const KEEP = 20
10const SHOWN_FILES = 8
11const ANSWER_CHARS = 3000
12const TABS: { key: SummaryTab; label: string; hotkey: string }[] = [
13  { key: 'files', label: '바꾼 파일', hotkey: '1' },
14  { key: 'did', label: '한 일', hotkey: '2' },
15  { key: 'learn', label: '배울 점', hotkey: '3' },
16]
17
18const entries = atom({ plugin: 'work-summary', key: 'entries' } as const, [])
19const tab = atom({ plugin: 'work-summary', key: 'tab' } as const, 'files')
20
21let turn = { id: '', prompt: '' }
22let changed = new Set<string>()
23
24export const register: Register = on => {
25  on('session.start', async ($, e, next) => {
26    await $.command.register({
27      name: 'work-summary',
28      description: '바꾼 파일·한 일·배울 점 패널을 엽니다',
29    })
30    void $.ui.open(OPEN)
31
32    return next(e)
33  })
34
35  on('command.run', { command: 'work-summary' }, async $ => {
36    await $.ui.open(OPEN)
37
38    return { text: '작업 요약 패널을 열었습니다.' }
39  })
40
41  on('turn.start', ($, e, next) => {
42    turn = { id: e.turnId, prompt: e.text }
43    changed = new Set()
44
45    return next(e)
46  })
47
48  on('tool.call', async ($, e, next) => {
49    const path = editedPath(e)
50    if (path === undefined) return next(e)
51    const ran = await next(e)
52    if (ran.deny === undefined && ran.isError !== true) changed.add(path)
53
54    return ran
55  })
56
57  on('turn.complete', async ($, e, next) => {
58    const done = await next(e)
59    if (e.agentId !== undefined || e.turnId !== turn.id || changed.size === 0) return done
60
61    const root = await $.session.root()
62    const entry: WorkSummary = {
63      turnId: e.turnId,
64      prompt: firstLine(turn.prompt),
65      files: [...changed].map(path => relative(root, path)),
66      status: 'pending',
67      did: [],
68      learn: [],
69      error: '',
70    }
71    changed = new Set()
72    await update($, entries, list => [entry, ...list].slice(0, KEEP))
73    $.clock.after(10, () => void summarize($, entry, e.answer))
74
75    return done
76  })
77
78  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
79    const { Box, Button, Text } = $.ui.resolve(e)
80    const list = await read($, entries)
81    if (list.length === 0) {
82      return <Text dimColor>파일을 바꾼 턴이 끝나면 여기에 요약이 쌓입니다.</Text>
83    }
84    const shown = await read($, tab)
85
86    return (
87      <Box flexDirection="column">
88        <Box columnGap={2} marginBottom={1}>
89          {TABS.map(one => (
90            <Button
91              key={one.key}
92              label={one.label}
93              hotkey={one.hotkey}
94              plain
95              dimColor={one.key !== shown}
96              onPress={() => void update($, tab, () => one.key)}
97            />
98          ))}
99        </Box>
100        {list.map(entry => (
101          <Box flexDirection="column" marginBottom={1}>
102            <Text bold wrap="truncate-end">▸ {entry.prompt}</Text>
103            {shown === 'files' && entry.files.slice(0, SHOWN_FILES).map(file => (
104              <Text wrap="truncate-start">  {file}</Text>
105            ))}
106            {shown === 'files' && entry.files.length > SHOWN_FILES && (
107              <Text dimColor>  외 {entry.files.length - SHOWN_FILES}개</Text>
108            )}
109            {shown !== 'files' && entry.status === 'pending' && <Text dimColor>  요약 중…</Text>}
110            {shown !== 'files' && entry.status === 'failed' && (
111              <Text dimColor>  요약 실패: {entry.error}</Text>
112            )}
113            {shown !== 'files' && entry.status === 'done' && entry[shown].length === 0 && (
114              <Text dimColor>  없음</Text>
115            )}
116            {shown !== 'files' && entry[shown].map(line => <Text>  • {line}</Text>)}
117          </Box>
118        ))}
119      </Box>
120    )
121  })
122}
123
124function editedPath(e: ToolCallInput): string | undefined {
125  if (e.tool === 'Edit' || e.tool === 'Write') return e.file_path
126  if (e.tool === 'NotebookEdit') return e.notebook_path
127
128  return undefined
129}
130
131async function summarize($: EngineInterface, entry: WorkSummary, answer: string) {
132  const reply = await $.model.fork({ prompt: summaryPrompt(entry.files, answer) })
133  const patch = reply.isAnswered
134    ? parseSummary(reply.text)
135    : { status: 'failed' as const, error: reply.reason }
136  await update($, entries, list =>
137    list.map(one => (one.turnId === entry.turnId ? { ...one, ...patch } : one)),
138  )
139}
140
141function summaryPrompt(files: string[], answer: string): string {
142  return [
143    '[작업 요약 패널용 요청 — 대화에는 남지 않는다]',
144    '방금 끝난 턴에서 너는 아래 파일을 바꿨고, 마지막에 아래처럼 답했다.',
145    `바꾼 파일: ${files.join(', ')}`,
146    `마지막 답변: ${answer.slice(0, ANSWER_CHARS)}`,
147    '',
148    '도구를 쓰지 말고, 아래 형식 그대로 한국어로만 답해. 형식 밖의 말은 붙이지 마.',
149    '한 일:',
150    '- 무엇을 왜 바꿨는지 (1~3줄, 줄마다 70자 이내)',
151    '배울 점:',
152    '- 이번 작업에서 드러난, 다음에도 쓸 만한 구체적인 교훈 (1~2줄, 줄마다 70자 이내, 일반론 금지)',
153  ].join('\n')
154}
155
156export function parseSummary(text: string): Pick<WorkSummary, 'status' | 'did' | 'learn'> {
157  const did: string[] = []
158  const learn: string[] = []
159  let into: string[] | undefined
160  for (const raw of text.split('\n')) {
161    const line = raw.trim()
162    if (line.startsWith('한 일')) into = did
163    else if (line.startsWith('배울 점')) into = learn
164    else if (line.startsWith('-') && into !== undefined) into.push(line.replace(/^-\s*/, ''))
165  }
166  if (did.length + learn.length === 0) did.push(text.trim())
167
168  return { status: 'done', did, learn }
169}
170
171function firstLine(prompt: string): string {
172  const line = prompt.trim().split('\n')[0] ?? ''
173
174  return line === '' ? '(이어서 한 작업)' : line
175}
176
177function relative(root: string, path: string): string {
178  return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
179}
180
types/index.d.ts 18 lines
1export type WorkSummary = {
2  turnId: string
3  prompt: string
4  files: string[]
5  status: 'pending' | 'done' | 'failed'
6  did: string[]
7  learn: string[]
8  error: string
9}
10
11export type SummaryTab = 'files' | 'did' | 'learn'
12
13declare module 'claude-code' {
14  interface PluginState {
15    'work-summary': { entries: WorkSummary[]; tab: SummaryTab }
16  }
17}
18