game-harness 의 훅·스킬·조회 스크립트가 이 세션에서 동작하기 시작하면 상태줄·토스트·/harness 패널로 보여준다

게임 프로젝트(Unreal Engine / Unity)용 Claude Code 하네스. 어느 프로젝트에 들어가도 엔진·프로젝트·기획을 좌표로 빠르게 파악하고, 기능 요청에서 한 기능에 매몰되지 않고 유지보수가 가장 좋은 구조를 근거(변경 이력 계측 + 그림)와 함께 사람이 고르게 한다.
skills/
game-bootstrap/ 0층 · 프로젝트 세팅 (/game-bootstrap) · harness/ 훅 원본 · evals/ 평가
game-onboard/ 1층 · 엔진·프로젝트 파악 · scripts/gq.py, ue_q.py, cindex.py(clangd 의미 인덱스 · 증분·유니티는 cindex_speed.py, RIFF 는 clangd_riff.py), index_view.py(웹뷰)
game-design-doc/ 1층 · 기획서 → 사양 표 → 구현 대응·어긋남
game-architecture/ 2층 · 구현 전 구조 조사 · scripts/evidence.py, render.py
game-patterns/ 2층 · 패턴 카탈로그 + 게임 도메인 증상 + UE/Unity 관용구 (출처 표시)
game-testing/ 3층 · 검증 기준·테스트
game-mcp/ 4층 · 에디터 MCP 운용
mods/
game-harness-monitor/ Claude Code mod · 하네스 기능이 동작하면 상태줄·토스트·/harness 패널로 보인다
setup.bat 한 번에: 설치 + 훅 등록 + mod 등록 + 프로젝트 세팅 + 사용법 출력
uninstall.bat 제거: 스킬·훅·설정 항목 (+ 프로젝트 폴더를 주면 그 세팅도)
rules/common.md 모든 프로젝트 공용 규칙 (답변 길이). 설치 때 ~/.claude/CLAUDE.md 의 표시 구간에 들어간다
install.py / .bat 머신 설치: 스킬·훅, settings 조각 출력 (bat 는 더블클릭용)
bootstrap.bat 프로젝트 세팅: 폴더를 끌어다 놓으면 CLAUDE.local.md + 인덱스 선언
index_build.bat 인덱스 지금 만들기: 프로젝트 좌표 → 엔진 좌표 → clangd (프로젝트 증분, 엔진은 없을 때만) → 조회용 정리
index_view.bat 인덱스 웹뷰
setup.bat 더블클릭 (Windows) — 또는 게임 프로젝트 폴더를 setup.bat 위에 끌어다 놓는다. 한 번에:
~/.claude 에 설치하고 settings.json 에 훅과 mod(env.CLAUDE_CODE_PLUGIN_DIRS)를 등록한다 (원본은 settings.json.bak)CLAUDE.local.md + 인덱스 선언. CLAUDE.md 가 이미 있으면 라우팅 절만. 덮어쓰지 않는다)PowerShell 에서는 .\setup.bat "D:\Work\MyGame". 다시 돌려도 안전하다 — 다른 프로젝트를 추가할 때도 같은 파일. 그 뒤에는 그 프로젝트에서 Claude Code 를 새로 열고 평소처럼 요청하면 된다.
나눠서 하려면 install.bat(머신) + bootstrap.bat(프로젝트). 프로젝트 규칙 파일이 없으면 스킬이 거의 불리지 않는다 — 평가로 확인한 사실이다 (검증 기록).
인덱스를 지금 만들려면 프로젝트 폴더를 index_build.bat 위에 끌어다 놓는다 (더블클릭이면 하네스가 최근에 돈 프로젝트). 프로젝트 좌표 → 엔진 좌표 → clangd 의미 인덱스 순으로 돌고, 끝에 단계별 완료·건너뜀·실패와 이유를 보여 준다. 엔진 폴더를 못 찾으면 창에서 경로를 묻고 프로젝트별로 저장한다 (명령 창에서는 --engine-root <엔진 폴더>). clangd 도구는 setup 이 설치한다 — 받지 못했으면 clangd 단계만 건너뛰고, setup.bat 을 다시 돌리면 된다.
uninstall.bat 더블클릭 — 또는 세팅했던 프로젝트 폴더를 그 위에 끌어다 놓는다. 미리보기는 --dry-run.
~/.claude/skills/game-*·~/.claude/mods/game-harness-monitor(링크만 끊는다), 설치한 훅 파일, settings.json 의 해당 훅 항목과 mod 경로(원본은 .bak, 다른 훅·env 는 그대로), 하네스 이벤트 로그. --purge 면 엔진 인덱스와 setup 이 받은 clangd 도구도CLAUDE.local.md 는 CLAUDE.local.md.removed 로 이름만 바꾼다. 인덱스 선언·HandOff·프로젝트 인덱스는 지운다. CLAUDE.md 는 건드리지 않는다uninstall.bat "<폴더>" --project-onlysetup.batPython 3.10+, git (svn 은 선택 — 이력 신호용, 검증 안 됨), Claude Code 2.1.x. clangd·clangd-indexer(의미 인덱스용)는 setup 이 없을 때만 내려받는다 (clangd 23.1.0 고정, SHA-256 대조, Windows 약 63MB — 실패해도 나머지는 동작). 모니터 mod 는 Claude Code 의 함수 훅 플러그인(early access, 2.1.290 에서 확인)이 필요하다.
harness ● 기능 N개 · 마지막 … 과 토스트 — 모니터 mod. /harness 로 기록 패널.index_view.bat 더블클릭 — 인덱스 검색·그래프·검증과 하네스 이벤트 기록을 브라우저로. 프로젝트 폴더를 끌어다 놓으면 그 프로젝트, 그냥 더블클릭하면 하네스가 최근에 돈 프로젝트. 이미 떠 있으면 새로 띄우지 않고 연다. 화면 오른쪽 위에서 프로젝트를 바꾼다. (터미널: python ~/.claude/skills/game-onboard/scripts/index_view.py --open [--root <프로젝트>])hooks/register.tsx 196 lines1// game-harness-monitor — 하네스가 이 세션에서 "어떤 기능을 동작시키기 시작했는지" 보여준다.
2//
3// 근거 자료는 하네스 훅·스크립트가 남기는 이벤트 로그 하나다
4// (~/.claude/cache/game-harness/events.jsonl, 형식은 skills/game-bootstrap/harness/harness_events.py).
5// 이 mod 는 그 로그를 읽기만 한다. 로그에 쓰지 않고, 도구 호출을 막거나 바꾸지 않는다.
6//
7// 상태줄 harness ● 기능 N개 · 마지막 <기능> (이 세션에서 하나라도 동작한 뒤부터)
8// 토스트 기능이 이 세션에서 처음 동작할 때 한 번
9// /harness 패널: 기능별 횟수와 시간순 기록
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { HarnessEvent } from '../types'
14
15const PANE = 'game-harness'
16const POLL_MS = 3000
17const KEEP = 300
18const events = atom({ plugin: 'game-harness-monitor', key: 'events' } as const, [] as HarnessEvent[])
19
20const LABELS: Record<string, string> = {
21 'context.routing': '세션 라우팅 주입',
22 'handoff.inject': 'HandOff 주입',
23 'handoff.write': 'HandOff 작성',
24 'stuck.warn': '매몰 경고',
25 'mcp.guard': 'MCP 가드 경고',
26 'mcp.call': 'MCP 호출 기록',
27 'diagram.render': '그림 렌더 (브라우저)',
28}
29
30const PREFIXES: [string, string][] = [
31 ['skill.', '스킬 '],
32 ['script.', '조회 '],
33 ['session_start.', '세션 시작 명령 '],
34 ['index.', '인덱스 '],
35]
36
37export function label(feature: string): string {
38 const fixed = LABELS[feature]
39 if (fixed !== undefined) return fixed
40 for (const [prefix, name] of PREFIXES) {
41 if (feature.startsWith(prefix)) return name + feature.slice(prefix.length)
42 }
43 return feature
44}
45
46const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
47
48// 세션 id 가 있는 이벤트(훅이 남긴 것)는 id 로, 없는 것(스크립트가 직접 남긴 것)은 프로젝트 경로와 시각으로 가른다.
49export function belongs(ev: HarnessEvent, sessionId: string, root: string, startedAt: number): boolean {
50 if (ev.session !== '') return ev.session === sessionId
51 if (ev.ts < startedAt || ev.project === '') return false
52 const p = norm(ev.project)
53 const r = norm(root)
54 return r === p || r.startsWith(p + '/') || p.startsWith(r + '/')
55}
56
57export function parse(text: string): HarnessEvent[] {
58 const out: HarnessEvent[] = []
59 for (const line of text.split('\n')) {
60 if (line.trim() === '') continue
61 try {
62 const ev = JSON.parse(line) as HarnessEvent
63 if (typeof ev.feature === 'string' && typeof ev.ts === 'number') out.push(ev)
64 } catch {
65 // 반쯤 쓰인 줄은 다음 폴링에서 다시 읽힌다
66 }
67 }
68 return out
69}
70
71export function statusText(list: readonly HarnessEvent[]): string | undefined {
72 const last = list.at(-1)
73 if (last === undefined) return undefined
74 const features = new Set(list.map(ev => ev.feature)).size
75 const failed = list.filter(ev => !ev.ok).length
76 return `harness ● 기능 ${features}개 · 마지막 ${label(last.feature)}${last.ok ? '' : ' ✗'}` +
77 (failed > 0 ? ` · 실패 ${failed}` : '')
78}
79
80async function logPath($: EngineInterface): Promise<string> {
81 const custom = await $.env.get('GAME_HARNESS_EVENTS')
82 if (custom) return custom
83 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
84 return `${home.replace(/[\\/]+$/, '')}/.claude/cache/game-harness/events.jsonl`
85}
86
87// 모듈 변수는 핫 리로드 때 처음으로 돌아간다. 그래도 화면이 읽는 기록은 $.state 에 있어 남는다.
88let path = ''
89let seenMtime = -1
90let seenSize = -1
91let isPolling = false
92
93async function poll($: EngineInterface): Promise<void> {
94 if (path === '') path = await logPath($)
95 const stat = await $.fs.stat(path).catch(() => undefined)
96 if (stat === undefined || (stat.mtimeMs === seenMtime && stat.size === seenSize)) return
97 seenMtime = stat.mtimeMs
98 seenSize = stat.size
99 const text = await $.fs.read(path)
100 const sessionId = await $.session.id()
101 const root = await $.session.root()
102 const { startedAt } = await $.session.usage()
103 const mine = parse(typeof text === 'string' ? text : '')
104 .filter(ev => belongs(ev, sessionId, root, startedAt))
105 .slice(-KEEP)
106 const before = await read($, events)
107 if (mine.length === before.length && mine.at(-1)?.ts === before.at(-1)?.ts) return
108 const known = new Set(before.map(ev => ev.feature))
109 const fresh = [...new Set(mine.map(ev => ev.feature))].filter(f => !known.has(f))
110 await update($, events, () => mine)
111 $.ui.status(statusText(mine))
112 if (fresh.length > 0) {
113 $.ui.toast(`하네스 기능 시작: ${fresh.map(label).join(', ')}`)
114 }
115}
116
117function pollSafely($: EngineInterface): void {
118 if (isPolling) return
119 isPolling = true
120 poll($).catch(() => undefined).finally(() => { isPolling = false })
121}
122
123export const register: Register = on => {
124 on('session.start', async ($, e, next) => {
125 $.clock.every(POLL_MS, () => pollSafely($))
126 pollSafely($)
127 await $.command.register({
128 name: 'harness',
129 description: '이 세션에서 동작한 하네스 기능(훅·스킬·조회 스크립트)을 패널로 보여준다',
130 }).catch(() => undefined) // 명령 등록이 막혀도 상태줄·토스트는 돈다
131 return next(e)
132 })
133
134 // 하네스 추적 훅(PostToolUse)이 로그를 쓴 직후를 놓치지 않으려고, 해당 도구 호출 뒤에 한 번 더 읽는다.
135 on('tool.call', async ($, e, next) => {
136 const ran = await next(e)
137 const tool = String(e.tool)
138 if (tool === 'Skill' || tool === 'Bash' || tool === 'PowerShell' || tool.startsWith('mcp__')) {
139 $.clock.after(700, () => pollSafely($))
140 }
141 return ran
142 }).catch(($, e, next) => next(e))
143
144 on('command.run', { command: 'harness' }, async $ => {
145 await poll($).catch(() => undefined)
146 const opened = await $.ui.open({ id: PANE, title: 'Harness' })
147 const list = await read($, events)
148 if (!opened.isPlaced) {
149 return { text: `하네스 패널을 열지 못했다. 이 세션 기록 ${list.length}건 · 로그 ${path}` }
150 }
151 return { text: `하네스 패널을 열었다 (이 세션 기록 ${list.length}건).` }
152 })
153
154 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
155 const { Box, Text } = $.ui.resolve(e)
156 const list = await read($, events)
157 const width = Math.max(20, e.props.bodyColumns)
158 const room = Math.max(4, (e.viewport?.rows ?? 30) - 4)
159 if (list.length === 0) {
160 return (
161 <Box flexDirection="column">
162 <Text>이 세션에서 동작한 하네스 기능이 아직 없다.</Text>
163 <Text dimColor wrap="truncate-middle">로그: {path === '' ? '(아직 안 읽음)' : path}</Text>
164 </Box>
165 )
166 }
167 const byFeature = new Map<string, { count: number; failed: number; last: string }>()
168 for (const ev of list) {
169 const row = byFeature.get(ev.feature) ?? { count: 0, failed: 0, last: '' }
170 row.count += 1
171 row.failed += ev.ok ? 0 : 1
172 row.last = ev.t.slice(11)
173 byFeature.set(ev.feature, row)
174 }
175 const summary = [...byFeature.entries()]
176 const timeline = list.slice(-Math.max(1, room - summary.length - 2))
177 return (
178 <Box flexDirection="column">
179 <Text bold>기능 {summary.length}개 · 기록 {list.length}건</Text>
180 {summary.map(([feature, row]) => (
181 <Text wrap="truncate-end" color={row.failed > 0 ? 'warning' : undefined}>
182 {row.failed > 0 ? '✗' : '●'} {label(feature)} ×{row.count}
183 {row.failed > 0 ? ` (실패 ${row.failed})` : ''} · {row.last}
184 </Text>
185 ))}
186 <Text dimColor>{'─'.repeat(Math.min(width, 40))}</Text>
187 {timeline.map(ev => (
188 <Text wrap="truncate-end" dimColor={ev.ok} color={ev.ok ? undefined : 'error'}>
189 {ev.t.slice(11)} {ev.ok ? '✓' : '✗'} {label(ev.feature)}{ev.detail ? ` — ${ev.detail}` : ''}
190 </Text>
191 ))}
192 </Box>
193 )
194 })
195}
196types/index.d.ts 17 lines1export type HarnessEvent = {
2 ts: number
3 t: string
4 session: string
5 project: string
6 feature: string
7 ok: boolean
8 detail: string
9 source: string
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'game-harness-monitor': { events: HarnessEvent[] }
15 }
16}
17