SLOPSHOPPER

work-hub

작업 목록·병렬 에이전트 패널, 보안 파일 변경 확인, git 워크트리·PR 템플릿 커맨드

newpaneguardcommandtoastprocess
v0.1.0no licenseupdated 2026-10-26no1fc/claude-mods/work-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · work-hub
│ ┃ Work Hub ✕ › fix the failing auth test and add an audit log call │ ┃ 작업 (docs/task_list.md) │ ┃ 작업 목록 파일이 없습니다. ⏺ Read(src/auth.ts) │ ┃ + 작업 목록 만들기 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 병렬 에이전트 (0 진행 중) ⎿ 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 │ │ › /hub │ ⎿ work-hub: Work Hub 패널을 열었습니다. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Work Hub
작업 (docs/task_list.md) 작업 목록 파일이 없습니다. + 작업 목록 만들기 병렬 에이전트 (0 진행 중) 이 세션에서 실행된 에이전트가 없습니다.
README

claude-mods

Claude Code에서 쓰는 mod(함수 훅 플러그인) 모음 마켓플레이스입니다.

mod설명
work-hub작업 목록·병렬 에이전트 패널, 보안 파일 변경 확인, git 워크트리·PR 템플릿 커맨드

Claude Code mod API는 early access라 Claude Code 버전에 따라 동작이 바뀔 수 있습니다. (개발 기준: Claude Code 2.1.308)

사전 설정 (필수)

mod(함수 훅)는 아직 early access라, 설치한 플러그인의 mod는 기본적으로 로드되지 않습니다. ~/.claude/settings.json에 아래 env를 추가하세요(이미 env가 있으면 키만 추가):

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
  }
}

설정 후 새로 시작한 세션부터 적용됩니다. 설정 없이 /hub를 실행하면 Unknown command: /hub가 나옵니다.

설정 파일 대신 환경변수로 켜도 됩니다(Windows는 setx CLAUDE_CODE_ENABLE_FUNCTION_HOOKS 1 후 새 터미널, macOS·Linux는 셸 프로필에 export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).

설치

터미널 세션의 프롬프트에서:

/plugin install work-hub --marketplace no1fc/claude-mods

Add marketplace?에 y, 스코프는 user(기본값)를 고르면 바로 켜집니다.

셸에서:

claude plugin install work-hub --marketplace no1fc/claude-mods

데스크톱 앱(/plugin이 동작하지 않음): 프롬프트 입력창 옆 + → Plugins → Add marketplace에 no1fc/claude-mods → Add plugin.

릴리스 파일로 설치

Releases의 work-hub-<버전>.zip을 받아 원하는 폴더에 풀고:

claude --plugin-dir <압축을 푼 폴더>/work-hub

업데이트

claude plugin marketplace update claude-mods
claude plugin update work-hub

그다음 실행 중인 세션에서 /reload-plugins.

문제 해결

증상확인할 것
Unknown command: /hub사전 설정의 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS가 빠졌는지. claude --debug로 실행하면 디버그 로그에 hooks module work-hub@claude-mods not loaded 사유가 남습니다
Cannot add marketplace "claude-mods": its network source differs ...이미 같은 이름(claude-mods)으로 다른 출처(로컬 폴더 등)가 등록된 상태. 그 출처로 설치돼 있으면 그대로 쓰거나, claude plugin marketplace remove claude-mods 후 다시 설치
Git Bash에서 claude -p "/hub"가 경로로 바뀜MSYS_NO_PATHCONV=1 claude -p "/hub"

work-hub

패널 — /hub

  • 작업: 프로젝트의 docs/task_list.md 체크박스(- [ ] / - [x])를 읽어 진행률, ▶ 다음 작업, 남은 작업을 보여줍니다. 5초마다 다시 읽습니다.
  • 작업을 누르면 "이 작업의 진행 상황과 해야 할 내용을 정리해줘"가 프롬프트 입력창에 채워집니다. 확인 후 Enter.
  • "진행 n/m"을 누르면 전체 현황 정리 요청이 채워집니다.
  • 파일이 없으면 작업 목록 만들기 버튼으로 Claude에게 생성을 요청합니다.
  • 병렬 에이전트: 이 세션의 서브에이전트·팀원과 상태. 누르면 메인 화면이 그 에이전트 대화로 전환되고, ← 메인 대화로로 돌아옵니다.
  • 데스크톱 앱의 다른 세션 목록·분할 창은 mod API 범위 밖입니다. 다른 세션은 앱 사이드바에서 끌어다 놓아 나란히 볼 수 있습니다.

보안 파일 가드 (자동)

Claude가 아래 파일을 Edit·Write·NotebookEdit로 변경하거나 Bash·PowerShell 명령에서 다루려 하면 허용 / 차단 확인을 받습니다. 확인 창을 닫으면 차단됩니다.

  • .env, .env.* (.env.example·.sample·.template·.dist는 제외)
  • *.pem, *.key, *.p12, *.pfx, *.keystore, *.jks
  • id_rsa, id_ed25519 등 SSH 키, credentials*, secret(s).*

git 커맨드

커맨드동작
/worktreegit worktree list
/worktree <브랜치>저장소 옆에 <저장소>-<브랜치> 폴더로 워크트리 생성 (브랜치가 없으면 새로 만듦)
/pr-template기본 브랜치 대비 커밋·변경 파일로 PR 템플릿(Summary / Changes / Changed files / Test plan) 출력
/pr위 템플릿을 바탕으로 Claude가 PR 본문을 작성 (PR 생성·push는 하지 않음)

기본 브랜치는 origin/HEAD → main → master 순으로 찾습니다.

개발

이 저장소를 로컬 마켓플레이스로 등록하면 폴더를 직접 읽으므로, 수정 후 /reload-plugins만 하면 반영됩니다.

claude plugin marketplace add <이 저장소 경로>
claude plugin install work-hub@claude-mods

검증·테스트:

claude plugin validate work-hub
claude plugin test work-hub

릴리스: work-hub/.claude-plugin/plugin.json의 version을 올리고 커밋·push한 뒤 v<버전> 태그로 GitHub 릴리스를 만듭니다.

구조:

.claude-plugin/marketplace.json   # 마켓플레이스 목록
work-hub/
  .claude-plugin/plugin.json      # mod 매니페스트
  hooks/hooks.json                # 훅 모듈 경로
  hooks/register.tsx              # 훅 연결 ($ 사용 코드)
  hooks/lib.ts                    # 순수 로직 (작업 파싱, 보안 파일 판별, PR 템플릿)
  hooks/work-hub.test.tsx         # claude plugin test
Source 2 files
hooks/register.tsx 191 lines
1import type { Register } from 'claude-code'
2
3import {
4  CREATE_TASKS_PROMPT,
5  OVERVIEW_PROMPT,
6  TASK_FILE,
7  buildPrPrompt,
8  buildPrTemplate,
9  isValidBranch,
10  parseTasks,
11  sensitiveTarget,
12  taskPrompt,
13  worktreePath,
14} from './lib'
15import type { GitInfo, Task } from './lib'
16
17const PANE = 'work-hub'
18const TITLE = 'Work Hub'
19// ponytail: the pane polls every 5s for task-file and agent changes; hook the
20// edits and agent events instead if the redraw ever shows up as a cost.
21const REFRESH_MS = 5000
22const ALLOW = '허용'
23const BLOCK = '차단'
24const ACTIVE_AGENT = new Set(['pending', 'running', 'waiting', 'idle'])
25
26async function git($, args: string[]) {
27  const ran = await $.process.run(['git', ...args], { timeoutMs: 15000 })
28  return { isOk: ran.exitCode === 0, out: ran.stdout.trim(), err: ran.stderr.trim() }
29}
30
31async function baseBranch($): Promise<string> {
32  const origin = await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
33  if (origin.isOk && origin.out) return origin.out
34  for (const name of ['main', 'master']) {
35    if ((await git($, ['rev-parse', '--verify', '--quiet', name])).isOk) return name
36  }
37  return 'main'
38}
39
40async function gitInfo($): Promise<GitInfo | string> {
41  if (!(await git($, ['rev-parse', '--is-inside-work-tree'])).isOk) {
42    return '현재 폴더는 git 저장소가 아닙니다.'
43  }
44  const base = await baseBranch($)
45  const branch = (await git($, ['branch', '--show-current'])).out || 'HEAD'
46  const commits = await git($, ['log', '--oneline', '--no-merges', `${base}..HEAD`])
47  const stat = await git($, ['diff', '--stat', `${base}...HEAD`])
48  const status = await git($, ['status', '--short'])
49  return { branch, base, commits: commits.out, stat: stat.out, status: status.out }
50}
51
52async function readTasks($): Promise<Task[] | undefined> {
53  try {
54    return parseTasks(await $.fs.read(TASK_FILE))
55  } catch {
56    return undefined
57  }
58}
59
60async function runWorktree($, args: string): Promise<string> {
61  const branch = args.trim()
62  if (branch === '') {
63    const list = await git($, ['worktree', 'list'])
64    return list.isOk ? list.out : `git worktree list 실패: ${list.err}`
65  }
66  if (!isValidBranch(branch)) return `브랜치 이름이 올바르지 않습니다: ${branch}`
67
68  const root = await git($, ['rev-parse', '--show-toplevel'])
69  if (!root.isOk) return '현재 폴더는 git 저장소가 아닙니다.'
70  const path = worktreePath(root.out, branch)
71  const exists = (await git($, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).isOk
72  const added = await git(
73    $,
74    exists ? ['worktree', 'add', path, branch] : ['worktree', 'add', '-b', branch, path],
75  )
76  if (!added.isOk) return `워크트리 생성 실패: ${added.err}`
77  return `워크트리를 만들었습니다: ${path} (브랜치 ${branch}${exists ? '' : ', 새로 생성'}). 이 폴더로 새 세션을 열어 작업하세요.`
78}
79
80export const register: Register = on => {
81  on('session.start', async ($, e, next) => {
82    const commands = [
83      { name: 'hub', description: 'Work Hub 패널(작업 목록·에이전트)을 연다' },
84      { name: 'worktree', description: 'git 워크트리 생성(인자: 브랜치명) 또는 목록(인자 없음)', argumentHint: '[branch]' },
85      { name: 'pr-template', description: '현재 브랜치 커밋·변경 파일로 PR 템플릿을 출력한다' },
86      { name: 'pr', description: 'PR 템플릿을 바탕으로 Claude가 PR 본문을 작성한다' },
87    ]
88    for (const command of commands) await $.command.register(command)
89    $.clock.every(REFRESH_MS, () => $.ui.invalidate('ui.render'))
90
91    return next(e)
92  })
93
94  on('command.run', { command: 'hub' }, async $ => {
95    await $.ui.open({ id: PANE, title: TITLE })
96    return { text: 'Work Hub 패널을 열었습니다.' }
97  })
98
99  on('command.run', { command: 'worktree' }, async ($, e) => ({
100    text: await runWorktree($, e.args),
101  }))
102
103  on('command.run', { command: 'pr-template' }, async $ => {
104    const info = await gitInfo($)
105    return { text: typeof info === 'string' ? info : buildPrTemplate(info) }
106  })
107
108  on('command.run', { command: 'pr' }, async $ => {
109    const info = await gitInfo($)
110    if (typeof info === 'string') return { text: info }
111    await $.prompt.submit({ text: buildPrPrompt(info) })
112    return { text: `${info.branch} → ${info.base} PR 본문 작성을 요청했습니다.` }
113  })
114
115  on('tool.call', async ($, e, next) => {
116    const target = sensitiveTarget(String(e.tool), e as unknown as Record<string, unknown>)
117    if (target === undefined) return next(e)
118    const answer = await $.ui.ask(
119      `${e.tool}이(가) 보안 파일 ${target}에 접근·변경하려고 합니다. 허용할까요?`,
120      { header: '보안 파일', options: [ALLOW, BLOCK] },
121    )
122    return answer === ALLOW ? next(e) : { deny: `work-hub: ${target} 접근을 차단했습니다.` }
123  }).catch(($, e, next) =>
124    next.called ? next(e) : { deny: 'work-hub: 보안 파일 확인을 받지 못해 차단했습니다.' },
125  )
126
127  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
128    const { Box, Text, Button } = $.ui.resolve(e)
129    const tasks = await readTasks($)
130    const agents = await $.agent.list()
131    const viewing = e.props.view?.agentId
132    const fill = (text: string) => async () => {
133      const filled = await $.prompt.fill({ text }).catch(() => ({ isFilled: false }))
134      if (!filled.isFilled) $.ui.toast('프롬프트 입력창에 넣지 못했습니다. 열린 대화상자를 닫고 다시 눌러주세요.')
135    }
136    const remaining = (tasks ?? []).filter(task => !task.isDone)
137    const nextTask = remaining[0]
138
139    return (
140      <Box flexDirection="column" gap={1}>
141        <Box flexDirection="column">
142          <Text bold>작업 ({TASK_FILE})</Text>
143          {tasks === undefined && <Text dimColor>작업 목록 파일이 없습니다.</Text>}
144          {tasks === undefined && (
145            <Button key="create-tasks" plain onPress={fill(CREATE_TASKS_PROMPT)}>
146              + 작업 목록 만들기
147            </Button>
148          )}
149          {tasks !== undefined && (
150            <Button key="overview" plain onPress={fill(OVERVIEW_PROMPT)}>
151              {`진행 ${tasks.length - remaining.length}/${tasks.length} · 전체 현황 보기`}
152            </Button>
153          )}
154          {nextTask && (
155            <Button key="next-task" plain onPress={fill(taskPrompt(nextTask))}>
156              {`▶ 다음: ${nextTask.text}`}
157            </Button>
158          )}
159          {remaining.slice(1).map((task, i) => (
160            <Button key={`task-${i}`} plain onPress={fill(taskPrompt(task))}>
161              {`· ${task.text}`}
162            </Button>
163          ))}
164          {tasks !== undefined && remaining.length === 0 && <Text dimColor>남은 작업이 없습니다.</Text>}
165        </Box>
166        <Box flexDirection="column">
167          <Text bold>병렬 에이전트 ({agents.filter(a => ACTIVE_AGENT.has(a.status)).length} 진행 중)</Text>
168          {agents.length === 0 && <Text dimColor>이 세션에서 실행된 에이전트가 없습니다.</Text>}
169          {agents.map(agent => (
170            <Button
171              key={`agent-${agent.id}`}
172              plain
173              onPress={async () => {
174                const moved = await $.ui.view({ agentId: agent.id })
175                if (moved.deny) $.ui.toast(`대화를 열 수 없습니다: ${moved.deny}`)
176              }}
177            >
178              {`${agent.id === viewing ? '● ' : ''}${agent.description || agent.type} · ${agent.status}`}
179            </Button>
180          ))}
181          {viewing && (
182            <Button key="main-view" plain onPress={() => void $.ui.view({})}>
183              ← 메인 대화로
184            </Button>
185          )}
186        </Box>
187      </Box>
188    )
189  })
190}
191
hooks/lib.ts 124 lines
1// Pure helpers: no $ here (the validator follows $ only within register.tsx).
2
3export type Task = { text: string; isDone: boolean }
4
5export type GitInfo = {
6  branch: string
7  base: string
8  commits: string
9  stat: string
10  status: string
11}
12
13export const TASK_FILE = 'docs/task_list.md'
14
15const TASK_LINE = /^\s*(?:[-*+]|\d+\.)\s+\[( |x|X)\]\s+(.+?)\s*$/
16
17export function parseTasks(markdown: string): Task[] {
18  return markdown.split(/\r?\n/).flatMap(line => {
19    const match = TASK_LINE.exec(line)
20    return match ? [{ text: match[2], isDone: match[1] !== ' ' }] : []
21  })
22}
23
24// Templates of secrets are edited routinely, so they pass without a question.
25const SAFE_NAMES = /^\.env\.(example|sample|template|dist)$/i
26const SENSITIVE_NAMES = [
27  /^\.env$/i,
28  /^\.env\..+$/i,
29  /\.(pem|key|p12|pfx|keystore|jks)$/i,
30  /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
31  /^credentials(\.\w+)?$/i,
32  /^secrets?\.\w+$/i,
33]
34
35function baseName(path: string): string {
36  return path.split(/[\\/]/).pop() ?? path
37}
38
39export function isSensitivePath(path: string): boolean {
40  const name = baseName(path.trim().replace(/^['"]|['"]$/g, ''))
41  if (SAFE_NAMES.test(name)) return false
42  return SENSITIVE_NAMES.some(pattern => pattern.test(name))
43}
44
45// Returns the sensitive file a tool call touches, or undefined. For shells
46// every word of the command is checked: a read of .env is asked about too.
47export function sensitiveTarget(
48  tool: string,
49  input: Record<string, unknown>,
50): string | undefined {
51  if (tool === 'Edit' || tool === 'Write') {
52    const path = String(input.file_path ?? '')
53    return isSensitivePath(path) ? path : undefined
54  }
55  if (tool === 'NotebookEdit') {
56    const path = String(input.notebook_path ?? '')
57    return isSensitivePath(path) ? path : undefined
58  }
59  if (tool === 'Bash' || tool === 'PowerShell') {
60    const words = String(input.command ?? '').split(/[\s;|&<>()=,]+/)
61    return words.find(word => word !== '' && isSensitivePath(word))
62  }
63  return undefined
64}
65
66const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._\/-]+$/
67
68export function isValidBranch(name: string): boolean {
69  return BRANCH.test(name) && !name.endsWith('/') && !name.endsWith('.lock')
70}
71
72export function worktreePath(root: string, branch: string): string {
73  const parts = root.split(/[\\/]/)
74  const repo = parts.pop() ?? 'repo'
75  return [...parts, `${repo}-${branch.replace(/\//g, '-')}`].join('/')
76}
77
78const ATTRIBUTION = '🤖 Generated with [Claude Code](https://claude.com/claude-code)'
79
80export function buildPrTemplate(info: GitInfo): string {
81  const commits = info.commits.trim() || '(커밋 없음)'
82  const stat = info.stat.trim() || '(변경 없음)'
83  return [
84    `## Summary`,
85    `<!-- ${info.branch} → ${info.base}: 이 PR이 하는 일을 1~3줄로 -->`,
86    `- `,
87    ``,
88    `## Changes`,
89    ...commits.split(/\r?\n/).map(line => `- ${line}`),
90    ``,
91    `## Changed files`,
92    '```',
93    stat,
94    '```',
95    ``,
96    `## Test plan`,
97    `- [ ] `,
98    ``,
99    ATTRIBUTION,
100  ].join('\n')
101}
102
103export function buildPrPrompt(info: GitInfo): string {
104  const pending = info.status.trim()
105    ? `\n\n커밋되지 않은 변경이 있습니다(PR에 포함되지 않음):\n${info.status.trim()}`
106    : ''
107  return [
108    `현재 브랜치 \`${info.branch}\`의 작업으로 \`${info.base}\` 대상 PR 본문을 작성해줘.`,
109    `\`git log ${info.base}..HEAD\`와 \`git diff ${info.base}...HEAD\`를 직접 분석해서`,
110    `아래 템플릿의 Summary·Changes·Test plan을 구체적으로 채우고, 마지막 줄은 그대로 둬.`,
111    `PR 생성이나 push는 하지 말고 본문만 보여줘.${pending}`,
112    ``,
113    buildPrTemplate(info),
114  ].join('\n')
115}
116
117export function taskPrompt(task: Task): string {
118  return `${TASK_FILE}의 작업 "${task.text}"의 현재 진행 상황과, 완료하려면 해야 할 내용을 정리해줘.`
119}
120
121export const OVERVIEW_PROMPT = `${TASK_FILE} 기준으로 전체 작업 진행 상황, 다음 작업, 남은 작업을 정리해줘.`
122
123export const CREATE_TASKS_PROMPT = `현재 프로젝트를 분석해서 ${TASK_FILE}를 만들어줘. 각 작업은 "- [ ] 작업 내용" 체크박스 한 줄로, 완료된 작업은 "- [x]"로 표시해줘.`
124