Records metadata-only session telemetry (tokens per model and loop, turns, agents, friction) for project after-action reviews.

A Claude Code mod that records session metadata only (models, tokens, the engine cost ledger, time, subagents, friction counts) and turns it into a project after-action review (AAR): plan, actual, gap, improvement, with charts and copy-paste improvement prompts.
Recorded, per process run, as one JSON file on your own machine:
/debrief tag <label> (the only text kept)Never recorded: prompts, responses, tool inputs (including file paths and commands), command arguments, skill bodies, subagent descriptions or prompts, session message bodies. Reports show sessions as S1, S2, agents as S1-A1, external runs as X1; session IDs, paths and project folder names never reach the HTML. Every report value passes an export allowlist (key paths) and a value check (home paths, UUIDs, request IDs) before it is written.
| Part | Where | Role |
|---|---|---|
| Core recorder (mod) | debrief/hooks/ | Writes <root>/runs/<project>/<session>/<run>.json. No vendor, machine, team or person names in code |
| Report program | debrief/tools/debrief_report/ | Python 3 standard library only. Backfill, AAR, summary, compaction, ledger |
| Adapters (optional) | same program | External agent logs (Codex rollouts) and /insights data; silently skipped when absent |
| Profile (optional) | <root>/profile.json | Language, lanes you declare, thresholds, external agent types. Built by /debrief setup. Never committed. Without one, reports and the setup dialogue follow the terminal locale (LC_ALL, then LANG: ko_* is Korean, anything else English) |
<root> is DEBRIEF_DIR, else <CLAUDE_CONFIG_DIR or ~/.claude>/debrief.
/plugin marketplace add matrix-freed/debrief
/plugin install debrief@debrief
On Claude Code 2.1.275 or later this also works in one step:
/plugin install debrief --marketplace matrix-freed/debrief
Paste this into a Claude Code session:
Install the debrief plugin for me: add the marketplace matrix-freed/debrief, install
debrief@debrief, then tell me to restart the session. After the restart, run /debrief setup
and walk me through the questions. Do not change any other settings.
| Command | What it does | |
|---|---|---|
/debrief | This session in numbers (tokens by model and loop, cost, time, agents) | |
/debrief tag <label> | Marks a phase; the AAR timeline draws a band from here to the next tag | |
/debrief setup | Builds your profile (language, lanes, thresholds) | |
/debrief aar [--from ISO] [--to ISO] [--cards PATH] | Project AAR HTML for the current project, written under <root>/aar/ | |
/debrief backfill [--days N] | Imports closed past sessions from transcripts (metadata only) | |
/debrief compact [--dry-run] | Folds run files older than 30 days into monthly rollups and archives the raw files | |
| `/debrief ledger [list [--status S] \ | set ID STATUS]` | Improvement ledger: proposed, applied, verified, dropped |
The plan, actual, gap and improvement cards are written by people (or a session you ask) in a small cards.json; debrief does not generate judgement text. Improvements get ledger IDs and a self-contained apply prompt with a copy button.
python3 debrief/tools/debrief_report backfill --config ~/.claude --root <root> --days 30
python3 debrief/tools/debrief_report aar --project <project folder name> --cards cards.json \
--root <root> --tz +09:00 --out aar.html
python3 debrief/tools/debrief_report summary --session <session id> --root <root> --out summary.html
python3 debrief/tools/debrief_report compact --root <root> --dry-run
python3 debrief/tools/debrief_report ledger --root <root> list
On Windows use py -3 instead of python3. Every command prints a final RESULT {...} line.
Cost is shown as "engine ledger (API equivalent), session cumulative": the engine's own running total, summed as per-run differences. On a subscription plan it is an API-equivalent figure, not a bill. Sessions without a ledger count as zero and are flagged as estimated. Processed tokens are input + cache write + output; cache reads are shown separately.
shareConsent: false by default, and reports only contain what the export allowlist lets through.P0 (API probe), P1 (recorder) and P2 (report program, backfill, project AAR) are done; see P0-RESULTS.md, P1-RESULTS.md, P2-RESULTS.md. Known gaps from the first real AAR:
DEBRIEF_PARENT_SESSION--from/--to window selects sessions but does not clip inside a sessionMIT. See LICENSE.
Claude Code mod다. 세션 메타데이터만(모델, 토큰, 엔진 비용 장부, 시간, 서브에이전트, 마찰 횟수) 기록하고, 이를 프로젝트 AAR(사후 검토: 계획, 실제, 차이, 개선)로 만든다. AAR에는 도식과 복사해 쓰는 개선 프롬프트가 들어간다.
프로세스 실행 단위로, 본인 PC에 JSON 파일 하나로 남긴다.
/debrief tag <라벨>로 직접 입력한 페이즈 라벨(유일하게 남기는 텍스트)절대 기록하지 않는 것: 프롬프트, 응답, 도구 입력(파일 경로와 명령 포함), 명령 인자, 스킬 본문, 서브에이전트 설명·프롬프트, 세션 간 메시지 본문. 리포트에서 세션은 S1, S2, 에이전트는 S1-A1, 외부 실행은 X1로만 나온다. 세션 ID·경로·프로젝트 폴더 이름은 HTML에 들어가지 않는다. 리포트의 모든 값은 쓰기 전에 내보내기 허용 목록(키 경로)과 값 검사(홈 경로, UUID, 요청 ID)를 통과해야 한다.
| 부분 | 위치 | 역할 |
|---|---|---|
| 코어 기록기(mod) | debrief/hooks/ | <root>/runs/<프로젝트>/<세션>/<run>.json을 쓴다. 코드에 벤더·머신·팀·사람 이름 없음 |
| 리포트 프로그램 | debrief/tools/debrief_report/ | Python 3 표준 라이브러리만. 백필, AAR, 서머리, 압축, 장부 |
| 어댑터(선택) | 같은 프로그램 | 외부 에이전트 로그(Codex rollout)와 /insights 데이터. 없으면 조용히 건너뜀 |
| 프로필(선택) | <root>/profile.json | 언어, 직접 선언한 레인, 임계값, 외부 에이전트 유형. /debrief setup이 만든다. 저장소에 넣지 않는다. 프로필이 없으면 보고서와 setup 대화는 터미널 로케일(LC_ALL, 없으면 LANG: ko_*면 한국어, 그 외 영어)을 따른다 |
<root>는 DEBRIEF_DIR, 없으면 <CLAUDE_CONFIG_DIR 또는 ~/.claude>/debrief.
/plugin marketplace add matrix-freed/debrief
/plugin install debrief@debrief
Claude Code 2.1.275 이상에서는 한 줄로도 된다.
/plugin install debrief --marketplace matrix-freed/debrief
Claude Code 세션에 그대로 붙여 넣는다.
debrief 플러그인을 설치해 줘. 마켓플레이스 matrix-freed/debrief 를 추가하고
debrief@debrief 를 설치한 뒤, 세션을 다시 시작하라고 알려 줘. 재시작 후 /debrief setup 을
실행해서 질문에 답하도록 안내해 줘. 다른 설정은 바꾸지 마.
| 명령 | 하는 일 | |
|---|---|---|
/debrief | 이 세션의 숫자(모델·루프별 토큰, 비용, 시간, 에이전트) | |
/debrief tag <라벨> | 페이즈 표시. AAR 타임라인에 여기서 다음 태그까지 띠로 그린다 | |
/debrief setup | 프로필 만들기(언어, 레인, 임계값) | |
/debrief aar [--from ISO] [--to ISO] [--cards 경로] | 현재 프로젝트의 AAR HTML. <root>/aar/ 아래에 쓴다 | |
/debrief backfill [--days N] | 지난 종료 세션을 transcript에서 가져온다(메타데이터만) | |
/debrief compact [--dry-run] | 30일 지난 run 파일을 월별 rollup으로 접고 원본은 아카이브로 옮긴다 | |
| `/debrief ledger [list [--status S] \ | set ID 상태]` | 개선 장부: proposed, applied, verified, dropped |
계획·실제·차이·개선 카드는 사람(또는 요청받은 세션)이 작은 cards.json에 쓴다. debrief는 판정 문장을 만들지 않는다. 개선 항목에는 장부 ID와 자기완결 적용 프롬프트, 복사 버튼이 붙는다.
python3 debrief/tools/debrief_report backfill --config ~/.claude --root <root> --days 30
python3 debrief/tools/debrief_report aar --project <프로젝트 폴더 이름> --cards cards.json \
--root <root> --tz +09:00 --out aar.html
python3 debrief/tools/debrief_report summary --session <세션 ID> --root <root> --out summary.html
python3 debrief/tools/debrief_report compact --root <root> --dry-run
python3 debrief/tools/debrief_report ledger --root <root> list
Windows에서는 python3 대신 py -3. 모든 명령은 마지막에 RESULT {...} 줄을 출력한다.
비용은 "엔진 장부(API 환산), 세션 누적"으로 표시한다. 엔진이 직접 쌓는 누적값을 run별 차로 더한 것이다. 구독 요금제에서는 청구액이 아니라 API 환산값이다. 장부가 없는 세션은 0으로 세고 추정으로 표시한다. 처리 토큰은 입력 + 캐시 쓰기 + 출력이고, 캐시 읽기는 따로 보여 준다.
shareConsent: false이고, 리포트에는 내보내기 허용 목록을 통과한 값만 들어간다.P0(API 실측), P1(기록기), P2(리포트 프로그램, 백필, 프로젝트 AAR) 완료. P0-RESULTS.md, P1-RESULTS.md, P2-RESULTS.md 참고. 첫 실데이터 AAR에서 확인한 알려진 갭:
DEBRIEF_PARENT_SESSION을 넘기지 않으면 부모 표식이 없다--from/--to 창은 세션을 고를 뿐 세션 안을 자르지 않는다MIT. LICENSE 참고.
hooks/register.ts 712 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DebriefInstruction, DebriefProfile, DebriefRun } from '../types'
5import { readInsights } from './insights'
6import { join, places, profileFile, runFile, sessionDir, slugOf } from './paths'
7import type { Places } from './paths'
8import { DEFAULT_PROFILE, directiveLabels, localeLanguage, normalizeProfile } from './profile'
9import { report, REPORT_HINT, REPORT_USAGE } from './report'
10import {
11 addInstruction,
12 addStep,
13 applySpawn,
14 completeTurn,
15 emptyRun,
16 serialize,
17 startTurn,
18 statusText,
19 summaryText,
20} from './record'
21
22// debrief: a metadata-only recorder. One file per process run under
23// <root>/runs/<project-slug>/<sessionId>/<runId>.json, rewritten whole.
24
25type $ = EngineInterface
26
27const RUN_REF = { plugin: 'debrief', key: 'run' } as const
28const RUN = atom(RUN_REF, null as DebriefRun | null)
29
30const TAG_MAX = 80
31
32// Module state: rebuilt on every load; the record itself lives in $.state.
33let profile: DebriefProfile = DEFAULT_PROFILE
34let profileSource: DebriefRun['profileSource'] = 'default'
35let placesCache: Places | undefined
36let early: ((run: DebriefRun) => void)[] = []
37// Every change bumps `generation`; a write records the generation it read.
38// The record is dirty while the two differ (Codex review 4).
39let generation = 0
40let writtenGeneration = 0
41let rotating: Promise<void> | null = null
42// Which run is installed, as this module last set it. `$.state.get` reads one
43// moment per dispatch, so a dispatch that waited on a rotation cannot see it
44// there; and a /clear starts the session's state over (observed live), so
45// the run it ended is only known here.
46type Live = { sessionId: string; runId: string; isInteractive: boolean; isEnded: boolean }
47let live: Live | null = null
48const liveOf = (run: DebriefRun): Live => ({
49 sessionId: run.sessionId,
50 runId: run.runId,
51 isInteractive: run.run.isInteractive,
52 isEnded: run.run.endedAt !== undefined,
53})
54let writing: Promise<void> | null = null
55let writeAgain = false
56const writes = { ok: 0, failed: 0, lastError: undefined as string | undefined }
57let checkpoint: { cancel: () => void } | undefined
58// Re-entry kinds are counted where `$` cannot be called (a tool.call .catch)
59// and folded into the record later as a fixed batch (Codex review 1, 6).
60let reentry: Record<string, number> = {}
61
62const where = async ($: $): Promise<Places> => (placesCache ??= await places($))
63
64const hex4 = () => {
65 const bytes = new Uint8Array(2)
66 crypto.getRandomValues(bytes)
67 return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('')
68}
69
70const sha = async (text: string): Promise<string> => {
71 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
72 return [...new Uint8Array(digest)].slice(0, 8).map(b => b.toString(16).padStart(2, '0')).join('')
73}
74
75const errorName = (error: unknown) => (error instanceof Error ? error.name : 'unknown')
76
77const isDirty = () => generation !== writtenGeneration
78
79// Recording never stands between an event and the rest of its chain.
80const guard = async (work: () => Promise<void>) => {
81 try {
82 await work()
83 } catch {
84 // fail-open
85 }
86}
87
88// Applies fn to the live run. With no run yet (events before session.start,
89// P0) the change waits in `early` and is applied when the run is made.
90const mutate = async ($: $, fn: (run: DebriefRun) => void) => {
91 let isDeferred = false
92 await update($, RUN, run => {
93 if (run === null) {
94 isDeferred = true
95 return run
96 }
97 isDeferred = false
98 const copy = structuredClone(run) as DebriefRun
99 fn(copy)
100 return copy
101 })
102 if (isDeferred) early.push(fn)
103 generation += 1
104}
105
106const applyEarly = (run: DebriefRun) => {
107 for (const fn of early) fn(run)
108 early = []
109}
110
111// Applies what arrived while no run was installed, as one fixed batch.
112const drainEarly = async ($: $) => {
113 if (early.length === 0) return
114 const batch = early
115 early = []
116 await mutate($, run => {
117 for (const fn of batch) fn(run)
118 })
119}
120
121const writeOnce = async ($: $) => {
122 const seen = generation
123 const run = await read($, RUN)
124 if (run === null) return
125 const p = await where($)
126 const now = await $.clock.now()
127 const withWrites: DebriefRun = {
128 ...run,
129 writes: { ok: writes.ok + 1, failed: writes.failed, ...(writes.lastError ? { lastError: writes.lastError } : {}) },
130 }
131 const { text } = serialize(withWrites, profile, now)
132 try {
133 await $.fs.write(runFile(p, run.project.slug, run.sessionId, run.runId), text)
134 writes.ok += 1
135 writtenGeneration = Math.max(writtenGeneration, seen)
136 } catch (error) {
137 writes.failed += 1
138 writes.lastError = errorName(error)
139 }
140}
141
142// One write in flight; a flush asked for meanwhile runs once more after it.
143const flush = ($: $): Promise<void> => {
144 if (writing) {
145 writeAgain = true
146 return writing
147 }
148 writing = (async () => {
149 do {
150 writeAgain = false
151 await writeOnce($)
152 } while (writeAgain)
153 })()
154 .catch(() => undefined)
155 .finally(() => {
156 writing = null
157 })
158 return writing
159}
160
161const loadProfile = async ($: $, p: Places) => {
162 try {
163 const path = profileFile(p)
164 if (await $.fs.exists(path)) {
165 const { profile: loaded, isValid } = normalizeProfile(JSON.parse(await $.fs.read(path)))
166 profile = loaded
167 profileSource = isValid ? 'file' : 'invalid'
168 return
169 }
170 const stored = await $.store.get('profile')
171 if (stored !== undefined) {
172 const { profile: loaded, isValid } = normalizeProfile(stored)
173 profile = loaded
174 profileSource = isValid ? 'store' : 'invalid'
175 return
176 }
177 } catch {
178 profileSource = 'invalid'
179 }
180 profile = DEFAULT_PROFILE
181 if (profileSource !== 'invalid') profileSource = 'default'
182}
183
184const newRun = async ($: $, surface: string | null, isInteractive: boolean, rotatedFrom?: string): Promise<DebriefRun> => {
185 const now = await $.clock.now()
186 let usdAtStart: number | undefined
187 try {
188 usdAtStart = (await $.session.usage()).cost?.usd
189 } catch {
190 usdAtStart = undefined
191 }
192 return emptyRun({
193 sessionId: await $.session.id(),
194 runId: `${now}-${hex4()}`,
195 now,
196 engine: (await $.session.version()).version,
197 // The session root, not the shell cwd: a `cd` the agent runs must not move the record.
198 slug: slugOf(await $.session.root()),
199 projectId: (await $.env.get('DEBRIEF_PROJECT')) ?? undefined,
200 parentSessionId: (await $.env.get('DEBRIEF_PARENT_SESSION')) ?? undefined,
201 surface,
202 isInteractive,
203 usdAtStart,
204 rotatedFrom,
205 profileSource,
206 })
207}
208
209// After a /clear the process goes on under a new session id with no
210// session.start: the first sign of it opens a new run.
211// One rotation at a time; each caller checks again once the one before it
212// is done, so two first signs never install two runs (Codex review 2).
213const ensureCurrent = async ($: $) => {
214 const isCurrent = async () => {
215 if (live === null) {
216 const run = await read($, RUN)
217 if (run !== null) live = liveOf(run)
218 }
219 return live !== null && live.sessionId === (await $.session.id()) && !live.isEnded
220 }
221 for (;;) {
222 while (rotating) await rotating
223 if (await isCurrent()) return
224 // no await between this check and the claim below
225 if (rotating === null) break
226 }
227 rotating = (async () => {
228 if (await isCurrent()) return
229 const old = live
230 const surface = await $.session.surface()
231 const next = await newRun($, surface, old?.isInteractive ?? surface !== null, old?.runId)
232 applyEarly(next)
233 await $.state.set(RUN_REF, next)
234 live = liveOf(next)
235 generation += 1
236 })().finally(() => {
237 rotating = null
238 })
239 await rotating
240 await drainEarly($)
241 await flush($)
242}
243
244// Fingerprints when the engine's InstructionsLoaded did not reach us (an
245// organization-managed install): the files the engine would load. Hashes only.
246const scanInstructions = async ($: $, p: Places): Promise<DebriefInstruction[]> => {
247 const out: DebriefInstruction[] = []
248 try {
249 const found = await $.fs.ancestors({ names: ['CLAUDE.md', 'CLAUDE.local.md'] })
250 const files = found.map(f => ({ path: join(p.sep, f.dir, f.name), text: f.content, memoryType: 'Project' }))
251 const userFile = join(p.sep, p.configDir, 'CLAUDE.md')
252 if (await $.fs.exists(userFile)) files.push({ path: userFile, text: await $.fs.read(userFile), memoryType: 'User' })
253 for (const f of files) {
254 const stat = await $.fs.stat(f.path).catch(() => undefined)
255 out.push({
256 memoryType: f.memoryType,
257 loadReason: 'scan',
258 pathHash: await sha(f.path),
259 contentHash: await sha(f.text),
260 bytes: new TextEncoder().encode(f.text).length,
261 mtimeMs: stat?.mtimeMs ?? 0,
262 })
263 }
264 } catch {
265 // fail-open: no fingerprint is better than a failed session start
266 }
267 return out
268}
269
270// Takes the re-entry counts out as a fixed batch, so a retried state update
271// reads the same numbers, and puts them back if the update fails.
272const foldReentry = async ($: $) => {
273 if (Object.keys(reentry).length === 0) return
274 const batch = reentry
275 reentry = {}
276 try {
277 await mutate($, run => {
278 for (const [kind, n] of Object.entries(batch)) run.toolCalls.reentry[kind] = (run.toolCalls.reentry[kind] ?? 0) + n
279 })
280 } catch {
281 for (const [kind, n] of Object.entries(batch)) reentry[kind] = (reentry[kind] ?? 0) + n
282 }
283}
284
285const commandOf = (input: unknown): string | undefined => {
286 if (typeof input !== 'object' || input === null) return undefined
287 const command = (input as { command?: unknown }).command
288 return typeof command === 'string' ? command : undefined
289}
290
291const sessionSummary = async ($: $): Promise<string> => {
292 const current = await read($, RUN)
293 const now = await $.clock.now()
294 if (current === null) return summaryText([], now)
295 const runs: DebriefRun[] = []
296 try {
297 const p = await where($)
298 const dir = sessionDir(p, current.project.slug, current.sessionId)
299 for (const entry of await $.fs.list(dir)) {
300 if (!entry.name.endsWith('.json') || entry.name === `${current.runId}.json`) continue
301 try {
302 runs.push(JSON.parse(await $.fs.read(join(p.sep, dir, entry.name))) as DebriefRun)
303 } catch {
304 continue
305 }
306 }
307 } catch {
308 // only the live run
309 }
310 runs.push(current)
311 runs.sort((a, b) => a.run.startedAt - b.run.startedAt)
312 return summaryText(runs, now)
313}
314
315// `/debrief setup`: show what was detected, ask what cannot be detected
316// (six questions at most), save the profile to the store and the profile file.
317// The language question comes first; the rest of the dialogue follows it.
318// Before that answer, the terminal locale decides (LC_ALL, then LANG).
319const setup = async ($: $): Promise<string> => {
320 const run = await read($, RUN)
321 let lang = localeLanguage((await $.env.get('LC_ALL')) || (await $.env.get('LANG')))
322 const t = (ko: string, en: string) => (lang === 'ko' ? ko : en)
323 if (run === null || !run.run.isInteractive) return t('setup은 대화형 세션에서만 됩니다. 건너뜁니다(skipped).', 'setup needs an interactive session; skipped.')
324 const p = await where($)
325 const insights = await readInsights($, p)
326 const models = Object.keys(Object.assign({}, ...Object.values(run.usage)))
327 const os = p.sep === '\\' ? 'Windows' : 'macOS/Linux'
328 const next: DebriefProfile = structuredClone(profile)
329 // Each option is [stored value, Korean label, English label]; the answer maps back to the value.
330 const choose = async (ko: string, en: string, header: string, choices: readonly (readonly [string, string, string])[]): Promise<string> => {
331 const labels = choices.map(c => t(c[1], c[2]))
332 const answer = await $.ui.ask(t(ko, en), { options: labels, header })
333 return choices.find(c => t(c[1], c[2]) === answer)?.[0] ?? answer
334 }
335 try {
336 const language = await choose('debrief의 보고서와 안내 언어를 고르세요.', 'Which language should debrief use for reports and prompts?', 'debrief',
337 lang === 'ko' ? [['ko', '한국어', 'Korean'], ['en', 'English', 'English']] : [['en', 'English', 'English'], ['ko', '한국어', 'Korean']])
338 if (language === 'en' || language === 'ko') {
339 lang = language
340 next.language = language
341 }
342 const detected = [
343 t(`시스템 ${os}`, `system ${os}`),
344 t(`이 세션의 모델: ${models.length > 0 ? models.join(', ') : '아직 없음'}`, `models this session: ${models.length > 0 ? models.join(', ') : 'none yet'}`),
345 t(`이 세션의 서브에이전트: ${Object.keys(run.agents).length}개`, `subagents this session: ${Object.keys(run.agents).length}`),
346 insights.available
347 ? t(`최근 세션 기록 ${insights.sessions}개, 그중 서브에이전트 사용 ${insights.taskAgentSessions}개`, `${insights.sessions} recent sessions on file, ${insights.taskAgentSessions} used subagents`)
348 : t('/insights 데이터 없음', 'no /insights data found'),
349 ].join('; ')
350 const keep = await choose(`debrief가 감지한 것: ${detected}. 현재 프로필을 출발점으로 둘까요?`, `debrief detected: ${detected}. Keep the current profile as the starting point?`, 'debrief',
351 [['keep', '유지', 'Keep'], ['neutral', '초기화', 'Start neutral']])
352 if (keep === 'neutral') Object.assign(next, structuredClone(DEFAULT_PROFILE), { language: next.language })
353 const use = await choose('AAR은 주로 무엇에 쓰나요?', 'What are the after-action reviews mainly for?', t('AAR 용도', 'AAR use'),
354 [['personal', '개인', 'personal'], ['project', '프로젝트', 'project'], ['team', '팀', 'team']])
355 if (use === 'personal' || use === 'project' || use === 'team') next.aarUse = use
356 const share = await choose('직접 고른 보고서를 공유용으로 내보내도 될까요? (기록은 어느 쪽이든 이 기기에만 남습니다)',
357 'May reports you pick be exported to share? (records stay on this machine either way)', t('공유', 'Sharing'),
358 [['no', '아니요', 'No'], ['yes', '예', 'Yes']])
359 next.shareConsent = share === 'yes'
360 const style = await choose('가설 카드를 얼마나 적극적으로 띄울까요?', 'How eager should hypothesis cards be?', t('임계값', 'Thresholds'),
361 [['conservative', '보수적', 'conservative'], ['standard', '표준', 'standard']])
362 if (style === 'conservative' || style === 'standard') next.thresholdStyle = style
363 const skip = t('건너뛰기', 'Skip')
364 const lane = await $.ui.ask(t('메인 세션 레인을 무엇이라 부르나요? 기타(Other)에 이름을 적거나 건너뛰세요.', 'What do you call your main session lane? Type a name under Other, or skip.'), {
365 options: ['main', skip],
366 header: t('메인 레인', 'Main lane'),
367 })
368 if (lane !== skip && lane.trim() !== '') next.mainLane = lane.trim().slice(0, 40)
369 } catch {
370 return t('setup을 취소했습니다. 프로필은 그대로입니다.', 'setup cancelled; the profile is unchanged.')
371 }
372 try {
373 await $.store.set('profile', next)
374 await $.fs.write(profileFile(p), JSON.stringify(next, null, 2))
375 } catch (error) {
376 return t(`setup이 프로필을 저장하지 못했습니다 (${errorName(error)}).`, `setup could not save the profile (${errorName(error)}).`)
377 }
378 profile = next
379 profileSource = 'file'
380 await mutate($, r => {
381 r.profileSource = 'file'
382 })
383 return t('프로필을 저장했습니다. 레인·지시어·머신은 프로필 파일에서 고치고, /debrief setup은 언제든 다시 돌릴 수 있습니다.',
384 'profile saved. Edit lanes, directives and machines in the profile file; run /debrief setup again any time.')
385}
386
387export const register: Register = on => {
388 on('session.start', async ($, e, next) => {
389 await guard(async () => {
390 const p = await where($)
391 await loadProfile($, p)
392 const sid = await $.session.id()
393 const current = await read($, RUN)
394 const slug = slugOf(await $.session.root())
395 const sameSession = current !== null && current.sessionId === sid && current.run.endedAt === undefined
396 // A reload under a changed root (or a record keyed by an old cwd) starts a
397 // new run under the root's slug; the old run keeps what it has.
398 if (sameSession && current.project.slug === slug) {
399 writes.ok = current.writes.ok
400 writes.failed = current.writes.failed
401 live = liveOf(current)
402 await mutate($, run => {
403 run.run.reloads += 1
404 })
405 } else {
406 // Install the run first, then fingerprint: events that arrive meanwhile
407 // land on the installed run, not in a buffer nobody drains (Codex review 5).
408 const run = await newRun($, e.surface, e.isInteractive, sameSession ? current.runId : undefined)
409 applyEarly(run)
410 await $.state.set(RUN_REF, run)
411 live = liveOf(run)
412 generation += 1
413 await drainEarly($)
414 if (run.instructions.length === 0) {
415 const found = await scanInstructions($, p)
416 if (found.length > 0)
417 await mutate($, r => {
418 if (r.instructions.length === 0) for (const i of found) addInstruction(r, i)
419 })
420 }
421 }
422 })
423 await guard(async () => {
424 await $.command.register({
425 name: 'debrief',
426 description: '세션 요약, 태그, 프로필 설정, AAR, 백필, 압축 및 개선 장부',
427 argumentHint: REPORT_HINT,
428 immediate: true,
429 })
430 checkpoint?.cancel()
431 checkpoint = $.clock.every(profile.limits.checkpointMs, () => {
432 if (isDirty()) void flush($)
433 })
434 await flush($)
435 })
436 return next(e)
437 })
438
439 on('classic.SessionStart', async ($, e, next) => {
440 await guard(async () => {
441 if (e.source === 'clear') await ensureCurrent($)
442 await mutate($, run => {
443 run.classic.seen = true
444 run.classic.sessionStartSources.push(e.source)
445 })
446 })
447 return next(e)
448 }).catch(($, e, next) => next(e))
449
450 on('classic.InstructionsLoaded', async ($, e, next) => {
451 await guard(async () => {
452 let entry: DebriefInstruction | undefined
453 try {
454 const [text, stat] = await Promise.all([$.fs.read(e.file_path), $.fs.stat(e.file_path)])
455 entry = {
456 memoryType: e.memory_type,
457 loadReason: e.load_reason,
458 pathHash: await sha(e.file_path),
459 contentHash: await sha(text),
460 bytes: new TextEncoder().encode(text).length,
461 mtimeMs: stat.mtimeMs,
462 }
463 } catch {
464 entry = undefined
465 }
466 await mutate($, run => {
467 run.classic.seen = true
468 if (entry) addInstruction(run, entry)
469 })
470 })
471 return next(e)
472 }).catch(($, e, next) => next(e))
473
474 on('turn.start', async ($, e, next) => {
475 await guard(async () => {
476 if (e.agentId === undefined) await ensureCurrent($)
477 const now = await $.clock.now()
478 await mutate($, run => startTurn(run, e.agentId ?? 'main', e.turnId, now))
479 })
480 return next(e)
481 })
482
483 on('turn.step', async function* ($, e, next) {
484 const result = yield* next(e)
485 await guard(async () => {
486 const now = await $.clock.now()
487 const labels: string[] = []
488 for (const use of result.toolUses) {
489 const command = commandOf(use.input)
490 if (command !== undefined) labels.push(...directiveLabels(profile, command))
491 }
492 let status: string | undefined
493 await mutate($, run => {
494 addStep(
495 run,
496 {
497 loop: e.agentId ?? 'main',
498 turnId: e.turnId,
499 index: e.index,
500 model: result.usage?.model ?? e.model,
501 ...(e.effort === undefined ? {} : { effort: String(e.effort) }),
502 stopReason: result.stopReason,
503 usage:
504 result.usage === null
505 ? null
506 : {
507 in: result.usage.input_tokens,
508 out: result.usage.output_tokens,
509 cacheRead: result.usage.cache_read_input_tokens,
510 cacheWrite: result.usage.cache_creation_input_tokens,
511 },
512 toolNames: result.toolUses.map(t => t.name),
513 serverToolNames: (result.serverToolUses ?? []).map(t => t.name),
514 directiveLabels: labels,
515 at: now,
516 },
517 profile,
518 )
519 if (run.run.isInteractive) status = statusText(run, now)
520 })
521 if (status !== undefined) $.ui.status(status)
522 })
523 return result
524 })
525
526 on('turn.complete', async ($, e, next) => {
527 await guard(async () => {
528 const now = await $.clock.now()
529 await foldReentry($)
530 await mutate($, run =>
531 completeTurn(
532 run,
533 { loop: e.agentId ?? 'main', turnId: e.turnId, durationMs: e.durationMs, reason: e.reason, aborted: e.isAborted, at: now },
534 profile,
535 ),
536 )
537 await flush($)
538 })
539 return next(e)
540 })
541
542 on('agent.spawn', async ($, e, next) => {
543 const result = await next(e)
544 await guard(async () => {
545 const now = await $.clock.now()
546 await mutate($, run =>
547 applySpawn(
548 run,
549 {
550 ...(result.agentId === undefined ? {} : { agentId: result.agentId }),
551 isDenied: result.deny !== undefined,
552 subagentType: e.subagentType,
553 ...(result.model === undefined ? {} : { model: result.model }),
554 parentModel: e.parentModel,
555 ...(e.parentAgentId === undefined ? {} : { parentAgentId: e.parentAgentId }),
556 background: e.background,
557 fork: e.fork,
558 teammate: e.isTeammate === true,
559 ...(e.workflow ? { workflow: e.workflow } : {}),
560 resumed: e.resume !== undefined,
561 at: now,
562 },
563 profile,
564 ),
565 )
566 await flush($)
567 })
568 return result
569 }).catch(($, e, next) => next(e))
570
571 // Counts go straight into the record once the call settled, so a reload
572 // between a call and its turn's end loses nothing (Codex review 6).
573 on('tool.call', async ($, e, next) => {
574 const ran = await next(e)
575 await guard(() =>
576 mutate($, run => {
577 if (e.agentId === undefined) run.toolCalls.main += 1
578 else run.toolCalls.agent += 1
579 if (ran.deny !== undefined) run.toolCalls.denied += 1
580 else if (ran.isError === true) run.toolCalls.isError += 1
581 }),
582 )
583 return ran
584 }).catch(($, e, next) => {
585 reentry[next.error.kind] = (reentry[next.error.kind] ?? 0) + 1
586 return next(e)
587 })
588
589 on('classic.PostToolUseFailure', async ($, e, next) => {
590 await guard(() =>
591 mutate($, run => {
592 run.classic.seen = true
593 run.classic.postToolUseFailures += 1
594 if (e.is_interrupt === true) run.classic.interrupts += 1
595 }),
596 )
597 return next(e)
598 }).catch(($, e, next) => next(e))
599
600 on('classic.PreCompact', async ($, e, next) => {
601 await guard(() =>
602 mutate($, run => {
603 run.classic.seen = true
604 run.classic.compactions += 1
605 }),
606 )
607 return next(e)
608 }).catch(($, e, next) => next(e))
609
610 on('skill.prompt', async ($, e, next) => {
611 await guard(() =>
612 mutate($, run => {
613 run.skills[e.skill] = (run.skills[e.skill] ?? 0) + 1
614 }),
615 )
616 return next(e)
617 })
618
619 on('command.run', async ($, e, next) => {
620 await guard(() =>
621 mutate($, run => {
622 run.commands[e.command] = (run.commands[e.command] ?? 0) + 1
623 }),
624 )
625 return next(e)
626 }).catch(($, e, next) => next(e))
627
628 on('command.run', { command: 'debrief' }, async ($, e) => {
629 // Arguments are read by debrief's own command only; a tag label is the
630 // one piece of typed text debrief keeps (local only, never exported).
631 const args = e.args.trim()
632 if (args === '') return { text: await sessionSummary($) }
633 if (args === 'setup') return { text: await setup($) }
634 const tag = /^tag\s+(\S[\s\S]*)$/.exec(args)
635 if (tag?.[1] !== undefined) {
636 const label = tag[1].trim().slice(0, TAG_MAX)
637 const now = await $.clock.now()
638 await mutate($, run => {
639 run.tags.push({ label, at: now, turnIndex: run.turns.length })
640 })
641 await flush($)
642 return { text: `phase tagged "${label}".` }
643 }
644 // Write this session's run file before the report program reads the runs folder.
645 await flush($)
646 return { text: (await report($, args)) ?? REPORT_USAGE }
647 })
648
649 on('session.send', async ($, e, next) => {
650 const result = await next(e)
651 await guard(() =>
652 mutate($, run => {
653 const slot = (run.messaging.sent[e.origin.kind] ??= { queued: 0, refused: 0 })
654 if (result.isDelivered) slot.queued += 1
655 else slot.refused += 1
656 }),
657 )
658 return result
659 }).catch(($, e, next) => next(e))
660
661 on('session.receive', async ($, e, next) => {
662 await guard(() =>
663 mutate($, run => {
664 run.messaging.received[e.origin.kind] = (run.messaging.received[e.origin.kind] ?? 0) + 1
665 }),
666 )
667 return next(e)
668 }).catch(($, e, next) => next(e))
669
670 on('session.measure', async ($, e, next) => {
671 await guard(async () => {
672 const now = await $.clock.now()
673 await mutate($, run => {
674 if (e.context.percent !== undefined) run.context.percent = e.context.percent
675 run.context.window = e.context.window
676 if (e.cost !== undefined) {
677 run.cost.usdAtEnd = e.cost.usd
678 run.cost.at = now
679 run.cost.provenance = 'engine-ledger'
680 }
681 })
682 if (e.changed.includes('cost')) await flush($)
683 })
684 return next(e)
685 })
686
687 on('session.end', async ($, e, next) => {
688 await guard(async () => {
689 const now = await $.clock.now()
690 let usd: number | undefined
691 try {
692 usd = (await $.session.usage()).cost?.usd
693 } catch {
694 usd = undefined
695 }
696 await foldReentry($)
697 if (live !== null) live = { ...live, isEnded: true }
698 await mutate($, run => {
699 run.run.endedAt = now
700 run.run.endReason = e.reason
701 if (usd !== undefined) {
702 run.cost.usdAtEnd = usd
703 run.cost.at = now
704 run.cost.provenance = 'engine-ledger'
705 }
706 })
707 await flush($)
708 })
709 return next(e)
710 })
711}
712hooks/insights.ts 54 lines1import type { EngineInterface } from 'claude-code'
2
3import { join } from './paths'
4import type { Places } from './paths'
5
6// Adapter: the built-in /insights output, if this machine has it. Optional
7// and fail-open: a missing or unreadable folder answers `available: false`.
8// Only allowlisted numeric and boolean fields are read out of each file; the
9// free-text fields (first_prompt, brief_summary, friction_detail and the
10// like) are never copied, returned or stored.
11
12export type InsightsSummary = {
13 available: boolean
14 sessions: number
15 taskAgentSessions: number
16 toolErrors: number
17 interruptions: number
18}
19
20const NONE: InsightsSummary = { available: false, sessions: 0, taskAgentSessions: 0, toolErrors: 0, interruptions: 0 }
21
22const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
23
24export async function readInsights($: EngineInterface, p: Places, limit = 40): Promise<InsightsSummary> {
25 const candidates = [join(p.sep, p.configDir, 'usage-data', 'session-meta'), join(p.sep, p.home, '.claude', 'usage-data', 'session-meta')]
26 for (const dir of candidates) {
27 try {
28 if (!(await $.fs.exists(dir))) continue
29 const entries = (await $.fs.list(dir))
30 .filter(e => e.kind === 'file' && e.name.endsWith('.json'))
31 .sort((a, b) => b.mtimeMs - a.mtimeMs)
32 .slice(0, limit)
33 const out = { ...NONE, available: true }
34 for (const entry of entries) {
35 try {
36 const row: unknown = JSON.parse(await $.fs.read(join(p.sep, dir, entry.name)))
37 if (typeof row !== 'object' || row === null) continue
38 const r = row as Record<string, unknown>
39 out.sessions += 1
40 out.taskAgentSessions += r.uses_task_agent === true ? 1 : 0
41 out.toolErrors += num(r.tool_errors)
42 out.interruptions += num(r.user_interruptions)
43 } catch {
44 continue
45 }
46 }
47 return out
48 } catch {
49 continue
50 }
51 }
52 return NONE
53}
54hooks/paths.ts 34 lines1import type { EngineInterface } from 'claude-code'
2
3// Where debrief keeps its files, on macOS, Linux and Windows alike: the
4// home is HOME, else USERPROFILE; the separator is the one the home uses.
5
6export type Places = { home: string; configDir: string; root: string; sep: string }
7
8export const sepOf = (path: string): string => (path.includes('\\') && !path.includes('/') ? '\\' : '/')
9
10export const join = (sep: string, ...parts: string[]): string =>
11 parts
12 .filter(p => p !== '')
13 .map((p, i) => (i === 0 ? p.replace(/[\\/]+$/, '') : p.replace(/^[\\/]+|[\\/]+$/g, '')))
14 .join(sep)
15
16// The project key, as Claude Code names its per-project transcript folders.
17export const slugOf = (cwd: string): string => cwd.replace(/[^A-Za-z0-9]/g, '-')
18
19export async function places($: EngineInterface): Promise<Places> {
20 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '.'
21 const sep = sepOf(home)
22 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? join(sep, home, '.claude')
23 const root = (await $.env.get('DEBRIEF_DIR')) ?? join(sep, configDir, 'debrief')
24 return { home, configDir, root, sep: sepOf(root) }
25}
26
27export const runFile = (p: Places, slug: string, sessionId: string, runId: string): string =>
28 join(p.sep, p.root, 'runs', slug, sessionId, `${runId}.json`)
29
30export const sessionDir = (p: Places, slug: string, sessionId: string): string =>
31 join(p.sep, p.root, 'runs', slug, sessionId)
32
33export const profileFile = (p: Places): string => join(p.sep, p.root, 'profile.json')
34hooks/profile.ts 81 lines1import type { DebriefProfile } from '../types'
2
3// Neutral defaults: no lane, vendor, machine or team name. A profile file or
4// the store fills these in; everything works without either.
5export const DEFAULT_PROFILE: DebriefProfile = {
6 version: 1,
7 language: 'en',
8 aarUse: 'project',
9 shareConsent: false,
10 thresholdStyle: 'conservative',
11 lanes: {},
12 externalAgentTypes: [],
13 directives: [],
14 machines: [],
15 limits: { byteBudget: 3 * 1024 * 1024, stepRing: 2000, turnRing: 1000, checkpointMs: 30000 },
16}
17
18// Language from the terminal locale (LC_ALL, then LANG): ko_* answers ko,
19// anything else en. Used when no profile has chosen one.
20export const localeLanguage = (locale: string | undefined): 'en' | 'ko' => (/^ko/i.test((locale ?? '').trim()) ? 'ko' : 'en')
21
22const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
23
24const pick = <T extends string>(v: unknown, allowed: readonly T[], fallback: T): T =>
25 typeof v === 'string' && (allowed as readonly string[]).includes(v) ? (v as T) : fallback
26
27const positive = (v: unknown, fallback: number, min: number): number =>
28 typeof v === 'number' && Number.isFinite(v) && v >= min ? Math.floor(v) : fallback
29
30const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : [])
31
32const compiles = (pattern: string): boolean => {
33 try {
34 new RegExp(pattern)
35 return true
36 } catch {
37 return false
38 }
39}
40
41// Reads any JSON value into a full profile; unknown or bad fields fall back.
42export const normalizeProfile = (raw: unknown): { profile: DebriefProfile; isValid: boolean } => {
43 if (!isObject(raw)) return { profile: DEFAULT_PROFILE, isValid: false }
44 const d = DEFAULT_PROFILE
45 const limits = isObject(raw.limits) ? raw.limits : {}
46 const lanes: Record<string, string> = {}
47 if (isObject(raw.lanes)) for (const [k, v] of Object.entries(raw.lanes)) if (typeof v === 'string') lanes[k] = v
48 const directives = Array.isArray(raw.directives)
49 ? raw.directives
50 .filter(isObject)
51 .filter(x => typeof x.label === 'string' && typeof x.pattern === 'string' && compiles(x.pattern))
52 .map(x => ({ label: String(x.label), pattern: String(x.pattern) }))
53 : []
54 return {
55 isValid: true,
56 profile: {
57 version: 1,
58 language: pick(raw.language, ['en', 'ko'] as const, d.language),
59 aarUse: pick(raw.aarUse, ['personal', 'project', 'team'] as const, d.aarUse),
60 shareConsent: raw.shareConsent === true,
61 thresholdStyle: pick(raw.thresholdStyle, ['conservative', 'standard'] as const, d.thresholdStyle),
62 ...(typeof raw.mainLane === 'string' && raw.mainLane !== '' ? { mainLane: raw.mainLane } : {}),
63 lanes,
64 externalAgentTypes: strings(raw.externalAgentTypes),
65 directives,
66 machines: strings(raw.machines),
67 limits: {
68 byteBudget: positive(limits.byteBudget, d.limits.byteBudget, 1024),
69 stepRing: positive(limits.stepRing, d.limits.stepRing, 1),
70 turnRing: positive(limits.turnRing, d.limits.turnRing, 1),
71 checkpointMs: positive(limits.checkpointMs, d.limits.checkpointMs, 1000),
72 },
73 },
74 }
75}
76
77// Labels of the profile's directive patterns that match a shell command.
78// The command text is only tested, never kept.
79export const directiveLabels = (profile: DebriefProfile, command: string): string[] =>
80 profile.directives.filter(d => new RegExp(d.pattern).test(command)).map(d => d.label)
81hooks/report.ts 104 lines1import type { EngineInterface, ProcessRunResult } from 'claude-code'
2import { join, places, sepOf, slugOf } from './paths'
3
4export const REPORT_HINT = '[tag <label> | setup | aar [--from ISO] [--to ISO] [--cards PATH] | backfill [--days N] | compact [--dry-run] | ledger [list [--status S] | set ID STATUS]]'
5export const REPORT_USAGE = `사용법: /debrief ${REPORT_HINT}`
6const STATUSES = ['proposed', 'applied', 'verified', 'dropped']
7const TIMEOUTS: Record<string, number> = { aar: 600000, backfill: 600000, compact: 300000, ledger: 30000 }
8
9// Quotes group a value that has spaces; backslashes and shell syntax are not interpreted.
10const tokens = (text: string): string[] | undefined => {
11 const out: string[] = []
12 let value = '', quote = '', active = false
13 for (const char of text) {
14 if (quote) {
15 if (char === quote) quote = ''
16 else value += char
17 } else if (char === '"' || char === "'") {
18 quote = char
19 active = true
20 } else if (/\s/.test(char)) {
21 if (active) out.push(value)
22 value = ''
23 active = false
24 } else {
25 value += char
26 active = true
27 }
28 }
29 if (quote) return undefined
30 if (active) out.push(value)
31 return out
32}
33
34const allowed = (sub: string, args: string[]): boolean => {
35 if (sub === 'ledger') {
36 if (args[0] === 'set') return args.length === 3 && /^[a-z0-9][a-z0-9-]{2,63}$/.test(args[1] ?? '') && STATUSES.includes(args[2] ?? '')
37 return args[0] === 'list' && (args.length === 1 || (args.length === 3 && args[1] === '--status' && STATUSES.includes(args[2] ?? '')))
38 }
39 const flags = sub === 'aar' ? ['--from', '--to', '--cards'] : sub === 'backfill' ? ['--days'] : ['--dry-run']
40 const seen = new Set<string>()
41 for (let i = 0; i < args.length; i++) {
42 const flag = args[i] ?? ''
43 if (!flags.includes(flag) || seen.has(flag)) return false
44 seen.add(flag)
45 if (flag === '--dry-run') continue
46 const value = args[++i]
47 if (!value || value.startsWith('-')) return false
48 if (flag === '--days' && (!Number.isFinite(Number(value)) || Number(value) <= 0)) return false
49 }
50 return true
51}
52
53export async function report($: EngineInterface, text: string): Promise<string | undefined> {
54 const sub = text.split(/\s/, 1)[0] ?? ''
55 if (!Object.hasOwn(TIMEOUTS, sub)) return undefined
56 const parsed = tokens(text)
57 if (!parsed) return '허용되지 않은 인자입니다. ' + REPORT_USAGE
58 const args = parsed.slice(1)
59 if (sub === 'ledger' && args.length === 0) args.push('list')
60 if (!allowed(sub, args)) return '허용되지 않은 인자입니다. ' + REPORT_USAGE
61 const p = await places($)
62 const programDir = (await $.env.get('DEBRIEF_REPORT_DIR')) ?? join(p.sep, $.plugin.root, 'tools', 'debrief_report')
63 const fixed = ['--root', p.root]
64 if (sub === 'aar' || sub === 'backfill') {
65 const slug = slugOf(await $.session.root())
66 fixed.push('--project', slug, '--config', p.configDir)
67 if (sub === 'backfill') fixed.push('--exclude-session', await $.session.id())
68 else {
69 const date = new Date(await $.clock.now())
70 const pad = (n: number) => String(n).padStart(2, '0')
71 const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`
72 fixed.push('--out', join(p.sep, p.root, 'aar', slug, `${stamp}.html`))
73 }
74 }
75 // The program runs with the data root as cwd, so a relative --cards path is
76 // resolved against the session root here (the shell cwd moves with every cd).
77 const cards = args.indexOf('--cards')
78 const cardsPath = cards >= 0 ? args[cards + 1] : undefined
79 if (cardsPath !== undefined && /^[A-Za-z]:(?![\\/])/.test(cardsPath)) return '허용되지 않은 인자입니다(드라이브 상대 경로). ' + REPORT_USAGE
80 if (cardsPath !== undefined && !/^([\\/]|[A-Za-z]:[\\/])/.test(cardsPath)) {
81 const base = await $.session.root()
82 args[cards + 1] = join(sepOf(base), base, cardsPath)
83 }
84 const argv = [programDir, sub, ...fixed, ...args]
85 const init = { cwd: p.root, timeoutMs: TIMEOUTS[sub] }
86 let result: ProcessRunResult
87 try {
88 result = await $.process.run(['python3', ...argv], init)
89 } catch {
90 try {
91 result = await $.process.run(['py', '-3', ...argv], init)
92 } catch {
93 return 'python3가 필요합니다 — 설치 안내: https://www.python.org/downloads/'
94 }
95 }
96 // On failure show the program's RESULT line (its error field) and up to 20 stderr lines.
97 const lines = result.exitCode !== 0
98 ? [`실패(exit ${result.exitCode})`, ...result.stdout.split(/\r?\n/).filter(line => line.startsWith('RESULT ')), ...result.stderr.split(/\r?\n/).filter(line => line !== '').slice(0, 20)]
99 : result.stdout.split(/\r?\n/).filter(line => line.startsWith('RESULT '))
100 if (result.exitCode === 0 && lines.length === 0) lines.push('완료했으나 RESULT 줄이 없습니다.')
101 if (result.isStdoutTruncated) lines.push('출력 잘림')
102 return lines.join('\n')
103}
104hooks/record.ts 335 lines1import type { DebriefAgent, DebriefInstruction, DebriefProfile, DebriefRun, DebriefUsage } from '../types'
2
3// Pure record logic: every function takes the run and plain metadata and
4// mutates the run in place. No text of prompts, answers, tool inputs or
5// command arguments ever reaches these functions.
6
7export const SCHEMA = 3 as const
8export const RECORDER = '0.1.0'
9
10export type StepInput = {
11 loop: string
12 turnId: string
13 index: number
14 model: string
15 effort?: string
16 stopReason: string | null
17 usage: { in: number; out: number; cacheRead: number; cacheWrite: number } | null
18 toolNames: readonly string[]
19 serverToolNames: readonly string[]
20 directiveLabels: readonly string[]
21 at: number
22}
23
24export type SpawnInput = {
25 agentId?: string
26 isDenied: boolean
27 subagentType: string
28 model?: string
29 parentModel: string
30 parentAgentId?: string
31 background: boolean
32 fork: boolean
33 teammate: boolean
34 workflow?: { runId: string; agentIndex: number }
35 resumed: boolean
36 at: number
37}
38
39export type NewRunInput = {
40 sessionId: string
41 runId: string
42 now: number
43 engine: string
44 slug: string
45 projectId?: string
46 parentSessionId?: string
47 surface: string | null
48 isInteractive: boolean
49 usdAtStart?: number
50 rotatedFrom?: string
51 profileSource: DebriefRun['profileSource']
52}
53
54const bump = (counts: Record<string, number>, key: string, by = 1) => {
55 counts[key] = (counts[key] ?? 0) + by
56}
57
58export const emptyRun = (i: NewRunInput): DebriefRun => ({
59 schema: SCHEMA,
60 recorder: RECORDER,
61 engine: i.engine,
62 source: 'mod',
63 project: { slug: i.slug, ...(i.projectId ? { id: i.projectId } : {}) },
64 sessionId: i.sessionId,
65 runId: i.runId,
66 ...(i.parentSessionId ? { parentSessionId: i.parentSessionId } : {}),
67 run: {
68 startedAt: i.now,
69 ...(i.rotatedFrom ? { rotatedFrom: i.rotatedFrom } : {}),
70 surface: i.surface,
71 isInteractive: i.isInteractive,
72 reloads: 0,
73 lastCheckpointAt: i.now,
74 coverage: 'complete',
75 dropped: { steps: 0, turns: 0, agents: 0 },
76 },
77 usage: {},
78 effort: {},
79 steps: [],
80 turns: [],
81 openTurns: {},
82 cost: { ...(i.usdAtStart === undefined ? {} : { usdAtStart: i.usdAtStart }), provenance: i.usdAtStart === undefined ? 'unknown' : 'engine-ledger' },
83 context: {},
84 agents: {},
85 spawnDenied: 0,
86 workflowRuns: [],
87 requestedToolCalls: {},
88 serverToolUses: {},
89 toolCalls: { main: 0, agent: 0, isError: 0, denied: 0, reentry: {} },
90 classic: { seen: false, sessionStartSources: [], postToolUseFailures: 0, interrupts: 0, compactions: 0 },
91 friction: { aborted: 0, toolErrors: null, rejected: null, compactions: null },
92 skills: {},
93 commands: {},
94 messaging: { sent: {}, received: {} },
95 delegation: { external: {}, directives: {} },
96 tags: [],
97 instructions: [],
98 profileSource: i.profileSource,
99 writes: { ok: 0, failed: 0 },
100})
101
102// An agent is known from its first event: its turn.start and steps can come
103// before its spawn's result (P0), so the spawn merges into what is there.
104export const ensureAgent = (run: DebriefRun, agentId: string, at: number): DebriefAgent => {
105 const found = run.agents[agentId]
106 if (found) return found
107 const agent: DebriefAgent = {
108 resumed: false,
109 spawnAttempts: 0,
110 turns: 0,
111 durationMs: 0,
112 firstSeenAt: at,
113 seen: { start: 0, steps: 0, complete: 0, spawn: 0 },
114 }
115 run.agents[agentId] = agent
116 return agent
117}
118
119const ring = <T>(list: T[], cap: number): number => {
120 const over = list.length - cap
121 if (over <= 0) return 0
122 list.splice(0, over)
123 return over
124}
125
126export const startTurn = (run: DebriefRun, loop: string, turnId: string, at: number) => {
127 run.openTurns[turnId] = at
128 if (loop !== 'main') ensureAgent(run, loop, at).seen.start += 1
129}
130
131export const addStep = (run: DebriefRun, s: StepInput, profile: DebriefProfile) => {
132 const byModel = (run.usage[s.loop] ??= {})
133 const u: DebriefUsage = (byModel[s.model] ??= { in: 0, out: 0, cacheRead: 0, cacheWrite: 0, steps: 0, nullUsageSteps: 0 })
134 u.steps += 1
135 if (s.usage === null) u.nullUsageSteps += 1
136 else {
137 u.in += s.usage.in
138 u.out += s.usage.out
139 u.cacheRead += s.usage.cacheRead
140 u.cacheWrite += s.usage.cacheWrite
141 }
142 if (s.effort !== undefined) bump((run.effort[s.model] ??= {}), s.effort)
143 run.steps.push({
144 loop: s.loop,
145 turnId: s.turnId,
146 index: s.index,
147 model: s.model,
148 ...(s.effort === undefined ? {} : { effort: s.effort }),
149 stopReason: s.stopReason,
150 usage: s.usage,
151 at: s.at,
152 })
153 run.run.dropped.steps += ring(run.steps, profile.limits.stepRing)
154 const tools = (run.requestedToolCalls[s.loop] ??= {})
155 for (const name of s.toolNames) bump(tools, name)
156 for (const name of s.serverToolNames) bump(run.serverToolUses, name)
157 for (const label of s.directiveLabels) bump(run.delegation.directives, label)
158 if (s.loop !== 'main') ensureAgent(run, s.loop, s.at).seen.steps += 1
159}
160
161export const completeTurn = (
162 run: DebriefRun,
163 t: { loop: string; turnId: string; durationMs: number; reason: string; aborted: boolean; at: number },
164 profile: DebriefProfile,
165) => {
166 const startedAt = run.openTurns[t.turnId]
167 delete run.openTurns[t.turnId]
168 const lastTag = run.tags.length > 0 ? run.tags[run.tags.length - 1] : undefined
169 run.turns.push({
170 loop: t.loop,
171 turnId: t.turnId,
172 ...(startedAt === undefined ? {} : { startedAt }),
173 durationMs: t.durationMs,
174 reason: t.reason,
175 aborted: t.aborted,
176 ...(lastTag ? { tag: lastTag.label } : {}),
177 })
178 run.run.dropped.turns += ring(run.turns, profile.limits.turnRing)
179 if (t.aborted) run.friction.aborted += 1
180 if (t.loop !== 'main') {
181 const agent = ensureAgent(run, t.loop, t.at)
182 agent.seen.complete += 1
183 agent.turns += 1
184 agent.durationMs += t.durationMs
185 }
186}
187
188export const applySpawn = (run: DebriefRun, s: SpawnInput, profile: DebriefProfile) => {
189 if (s.isDenied || s.agentId === undefined) {
190 run.spawnDenied += 1
191 return
192 }
193 const agent = ensureAgent(run, s.agentId, s.at)
194 const isFirstSpawn = agent.seen.spawn === 0
195 agent.seen.spawn += 1
196 agent.spawnAttempts += 1
197 agent.subagentType = s.subagentType
198 if (s.model !== undefined) agent.model = s.model
199 agent.parentModel = s.parentModel
200 if (s.parentAgentId !== undefined) agent.parentAgentId = s.parentAgentId
201 agent.background = s.background
202 agent.fork = s.fork
203 agent.teammate = s.teammate
204 if (s.workflow) {
205 agent.workflowRunId = s.workflow.runId
206 agent.workflowIndex = s.workflow.agentIndex
207 if (s.workflow.runId !== '' && !run.workflowRuns.includes(s.workflow.runId)) run.workflowRuns.push(s.workflow.runId)
208 }
209 agent.resumed = agent.resumed || s.resumed
210 // A replayed spawn of the same agent is counted once as a delegation.
211 if (isFirstSpawn && profile.externalAgentTypes.includes(s.subagentType)) bump(run.delegation.external, s.subagentType)
212}
213
214export const addInstruction = (run: DebriefRun, i: DebriefInstruction) => {
215 const isKnown = run.instructions.some(
216 x => x.pathHash === i.pathHash && x.contentHash === i.contentHash && x.loadReason === i.loadReason,
217 )
218 if (!isKnown) run.instructions.push(i)
219}
220
221// Friction is derived when the record is written: rejections are tool calls
222// that ended in error with no PostToolUseFailure for them (P0). Without the
223// classic events the three classic-backed counters stay null (not measured).
224export const deriveFriction = (run: DebriefRun) => {
225 if (!run.classic.seen) {
226 run.friction.toolErrors = null
227 run.friction.rejected = null
228 run.friction.compactions = null
229 return
230 }
231 run.friction.toolErrors = run.classic.postToolUseFailures - run.classic.interrupts
232 run.friction.rejected = Math.max(0, run.toolCalls.isError - run.classic.postToolUseFailures)
233 run.friction.compactions = run.classic.compactions
234}
235
236const byteLength = (text: string) => new TextEncoder().encode(text).length
237
238// Serializes within the byte budget: oldest steps go first, then oldest
239// turns, then the oldest agents fold into one `other` entry. What was dropped
240// is counted and the coverage marked partial.
241export const serialize = (source: DebriefRun, profile: DebriefProfile, now: number): { text: string; run: DebriefRun } => {
242 const run = structuredClone(source) as DebriefRun
243 run.run.lastCheckpointAt = now
244 deriveFriction(run)
245 const budget = profile.limits.byteBudget
246 let text = JSON.stringify(run)
247 while (byteLength(text) > budget) {
248 if (run.steps.length > 0) {
249 const n = Math.max(1, Math.ceil(run.steps.length / 4))
250 run.steps.splice(0, n)
251 run.run.dropped.steps += n
252 } else if (run.turns.length > 0) {
253 const n = Math.max(1, Math.ceil(run.turns.length / 4))
254 run.turns.splice(0, n)
255 run.run.dropped.turns += n
256 } else {
257 const ids = Object.keys(run.agents)
258 .filter(id => id !== 'other')
259 .sort((a, b) => (run.agents[a]?.firstSeenAt ?? 0) - (run.agents[b]?.firstSeenAt ?? 0))
260 const victim = ids[0]
261 if (victim === undefined) break
262 const gone = run.agents[victim]
263 delete run.agents[victim]
264 const other = ensureAgent(run, 'other', gone?.firstSeenAt ?? now)
265 other.turns += gone?.turns ?? 0
266 other.durationMs += gone?.durationMs ?? 0
267 run.run.dropped.agents += 1
268 }
269 run.run.coverage = 'partial'
270 text = JSON.stringify(run)
271 }
272 return { text, run }
273}
274
275export type Totals = { in: number; out: number; cacheRead: number; cacheWrite: number; mainShare: number }
276
277export const totals = (runs: readonly DebriefRun[]): Totals => {
278 let all = 0
279 let main = 0
280 const t = { in: 0, out: 0, cacheRead: 0, cacheWrite: 0 }
281 for (const run of runs)
282 for (const [loop, byModel] of Object.entries(run.usage))
283 for (const u of Object.values(byModel)) {
284 t.in += u.in
285 t.out += u.out
286 t.cacheRead += u.cacheRead
287 t.cacheWrite += u.cacheWrite
288 const sum = u.in + u.out + u.cacheRead + u.cacheWrite
289 all += sum
290 if (loop === 'main') main += sum
291 }
292 return { ...t, mainShare: all === 0 ? 0 : main / all }
293}
294
295const k = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
296
297const minutes = (ms: number) => `${Math.max(0, Math.round(ms / 60000))}m`
298
299// The status line: input (all input kinds) and output tokens, run wall time,
300// main-loop share. A guard is shown, never raised as a warning.
301export const statusText = (run: DebriefRun, now: number): string => {
302 const t = totals([run])
303 return `in ${k(t.in + t.cacheRead + t.cacheWrite)} out ${k(t.out)} · ${minutes(now - run.run.startedAt)} · main ${Math.round(t.mainShare * 100)}%`
304}
305
306export const summaryText = (runs: readonly DebriefRun[], now: number): string => {
307 if (runs.length === 0) return 'nothing recorded for this session yet.'
308 const t = totals(runs)
309 const models = new Map<string, number>()
310 let agents = 0
311 let turns = 0
312 let usdStart: number | undefined
313 let usdEnd: number | undefined
314 for (const run of runs) {
315 agents += Object.keys(run.agents).length
316 turns += run.turns.length
317 for (const byModel of Object.values(run.usage))
318 for (const [model, u] of Object.entries(byModel)) models.set(model, (models.get(model) ?? 0) + u.out)
319 if (usdStart === undefined && run.cost.usdAtStart !== undefined) usdStart = run.cost.usdAtStart
320 if (run.cost.usdAtEnd !== undefined) usdEnd = run.cost.usdAtEnd
321 }
322 const first = runs[0]
323 const last = runs[runs.length - 1]
324 const wall = first && last ? (last.run.endedAt ?? now) - first.run.startedAt : 0
325 const modelList = [...models.entries()].map(([m, out]) => `${m} out ${k(out)}`).join(', ')
326 const cost =
327 usdEnd === undefined ? 'cost: not reported' : `cost (engine ledger, API-equivalent, session cumulative): $${usdEnd.toFixed(2)}`
328 return [
329 `${runs.length} run(s), ${turns} turns, ${agents} agents, ${minutes(wall)} wall.`,
330 `tokens: in ${k(t.in)} + cache read ${k(t.cacheRead)} + cache write ${k(t.cacheWrite)}, out ${k(t.out)}; main loop ${Math.round(t.mainShare * 100)}%.`,
331 `models: ${modelList || 'none'}.`,
332 cost + (usdStart !== undefined && usdStart > 0 ? ` (was $${usdStart.toFixed(2)} when this record began)` : '') + '.',
333 ].join('\n')
334}
335types/index.d.ts 158 lines1// debrief: the record contract (schema 3), the profile and the improvement
2// ledger. Metadata only: no prompt, answer, tool input, command argument,
3// message body or file path text is ever held in a record.
4
5export type DebriefCounts = { [name: string]: number }
6
7export type DebriefUsage = {
8 in: number
9 out: number
10 cacheRead: number
11 cacheWrite: number
12 steps: number
13 nullUsageSteps: number
14}
15
16export type DebriefStep = {
17 loop: string
18 turnId: string
19 index: number
20 model: string
21 effort?: string
22 stopReason: string | null
23 usage: { in: number; out: number; cacheRead: number; cacheWrite: number } | null
24 at: number
25}
26
27export type DebriefTurn = {
28 loop: string
29 turnId: string
30 startedAt?: number
31 durationMs: number
32 reason: string
33 aborted: boolean
34 tag?: string
35}
36
37export type DebriefAgent = {
38 subagentType?: string
39 model?: string
40 parentModel?: string
41 parentAgentId?: string
42 background?: boolean
43 fork?: boolean
44 teammate?: boolean
45 workflowRunId?: string
46 workflowIndex?: number
47 resumed: boolean
48 spawnAttempts: number
49 turns: number
50 durationMs: number
51 firstSeenAt: number
52 seen: { start: number; steps: number; complete: number; spawn: number }
53}
54
55export type DebriefInstruction = {
56 memoryType: string
57 loadReason: string
58 pathHash: string
59 contentHash: string
60 bytes: number
61 mtimeMs: number
62}
63
64export type DebriefRunInfo = {
65 startedAt: number
66 endedAt?: number
67 endReason?: string
68 rotatedFrom?: string
69 surface: string | null
70 isInteractive: boolean
71 reloads: number
72 lastCheckpointAt: number
73 coverage: 'complete' | 'partial'
74 dropped: { steps: number; turns: number; agents: number }
75}
76
77export type DebriefFriction = {
78 aborted: number
79 // null = not measured (the classic hook events did not reach this plugin)
80 toolErrors: number | null
81 rejected: number | null
82 compactions: number | null
83}
84
85export type DebriefRun = {
86 schema: 3
87 recorder: string
88 engine: string
89 source: 'mod'
90 project: { slug: string; id?: string }
91 sessionId: string
92 runId: string
93 parentSessionId?: string
94 run: DebriefRunInfo
95 usage: { [loop: string]: { [model: string]: DebriefUsage } }
96 effort: { [model: string]: DebriefCounts }
97 steps: DebriefStep[]
98 turns: DebriefTurn[]
99 openTurns: { [turnId: string]: number }
100 cost: { usdAtStart?: number; usdAtEnd?: number; at?: number; provenance: 'engine-ledger' | 'unknown' }
101 context: { percent?: number; window?: number }
102 agents: { [agentId: string]: DebriefAgent }
103 spawnDenied: number
104 workflowRuns: string[]
105 requestedToolCalls: { [loop: string]: DebriefCounts }
106 serverToolUses: DebriefCounts
107 toolCalls: { main: number; agent: number; isError: number; denied: number; reentry: DebriefCounts }
108 classic: { seen: boolean; sessionStartSources: string[]; postToolUseFailures: number; interrupts: number; compactions: number }
109 friction: DebriefFriction
110 skills: DebriefCounts
111 commands: DebriefCounts
112 messaging: { sent: { [originKind: string]: { queued: number; refused: number } }; received: DebriefCounts }
113 delegation: { external: DebriefCounts; directives: DebriefCounts }
114 tags: { label: string; at: number; turnIndex: number }[]
115 instructions: DebriefInstruction[]
116 profileSource: 'default' | 'file' | 'store' | 'invalid'
117 writes: { ok: number; failed: number; lastError?: string }
118}
119
120// A user's profile: names, patterns and thresholds live here, never in code.
121export type DebriefProfile = {
122 version: 1
123 language: 'en' | 'ko'
124 aarUse: 'personal' | 'project' | 'team'
125 shareConsent: boolean
126 thresholdStyle: 'conservative' | 'standard'
127 mainLane?: string
128 lanes: { [subagentType: string]: string }
129 externalAgentTypes: string[]
130 directives: { label: string; pattern: string }[]
131 machines: string[]
132 limits: { byteBudget: number; stepRing: number; turnRing: number; checkpointMs: number }
133}
134
135// The improvement ledger (generation is P2; the shape is fixed here).
136export type DebriefLedgerItem = {
137 title?: string
138 project?: string
139 recurrenceOf?: string[]
140 id: string
141 lever: 'template' | 'skill' | 'hook' | 'setting' | 'statusline' | 'instruction'
142 target: string
143 metric: string
144 status: 'proposed' | 'applied' | 'verified' | 'dropped'
145 applyPrompt: string
146 evidence?: string[]
147 createdAt?: string
148 updatedAt?: string
149}
150
151export type DebriefLedger = { version: 1; items: DebriefLedgerItem[] }
152
153declare module 'claude-code' {
154 interface PluginState {
155 debrief: { run: DebriefRun | null }
156 }
157}
158