SLOPSHOPPER

debrief

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

newguardcommandstatuspromptprocess
v0.1.2MITupdated 2026-10-16matrix-freed/debrief/debrief
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · debrief
› 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 › /debrief ⎿ debrief: 1 run(s), 1 turns, 0 agents, 0m wall. ⎿ debrief: tokens: in 0 + cache read 0 + cache write 0, out 0; main loop 0%. ⎿ debrief: models: none. ⎿ debrief: cost (engine ledger, API-equivalent, session cumulative): $0.42 (was $0.42 when this record began). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

debrief

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.

한국어는 아래에 있습니다.


What it records, and what it never records

Recorded, per process run, as one JSON file on your own machine:

  • tokens per loop (main or each subagent) and per model: input, output, cache read, cache write, requests
  • effort level counts, request timestamps (a bounded ring), turn timing
  • the engine cost ledger snapshot (session cumulative, API-equivalent USD)
  • subagents: declared type, model, first seen, duration, spawn attempts
  • friction counters: aborted requests, tool errors, rejected tools, compactions
  • tool and skill names with counts; session message counts by origin kind
  • instruction file fingerprints as hashes only (path hash, content hash, size)
  • phase tags you type yourself with /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.

Structure

PartWhereRole
Core recorder (mod)debrief/hooks/Writes <root>/runs/<project>/<session>/<run>.json. No vendor, machine, team or person names in code
Report programdebrief/tools/debrief_report/Python 3 standard library only. Backfill, AAR, summary, compaction, ledger
Adapters (optional)same programExternal agent logs (Codex rollouts) and /insights data; silently skipped when absent
Profile (optional)<root>/profile.jsonLanguage, 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.

Requirements

  • Claude Code 2.1.288 or later (mods are early access)
  • macOS, Windows or Linux
  • Python 3 for the report commands (no packages to install). Without it the recorder still works and the report commands print an install hint.

Install

/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

Let Claude install it

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.

Commands

CommandWhat it does
/debriefThis 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 setupBuilds 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.

Running the report program directly

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 label

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.

Privacy and what this is not

  • Records stay on your machine. Nothing is uploaded; there is no server.
  • There is no per-person ranking or comparison. The AAR is about a project's way of working (where time and tokens went, what to change next), and its hypotheses are phrased as questions, not verdicts.
  • Sharing is opt-in: the profile has shareConsent: false by default, and reports only contain what the export allowlist lets through.

Status

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:

  • sessions recorded before the mod was enabled have no cost ledger (counted as zero, flagged)
  • automated one-shot SDK sessions are folded into one row by a simple rule (entrypoint and length)
  • child sessions started for experiments carry no parent marker unless the parent sets DEBRIEF_PARENT_SESSION
  • the --from/--to window selects sessions but does not clip inside a session
  • open-session detection for backfill uses file idle time

License

MIT. See LICENSE.


한국어

debrief

Claude Code mod다. 세션 메타데이터만(모델, 토큰, 엔진 비용 장부, 시간, 서브에이전트, 마찰 횟수) 기록하고, 이를 프로젝트 AAR(사후 검토: 계획, 실제, 차이, 개선)로 만든다. AAR에는 도식과 복사해 쓰는 개선 프롬프트가 들어간다.

기록하는 것과 절대 기록하지 않는 것

프로세스 실행 단위로, 본인 PC에 JSON 파일 하나로 남긴다.

  • 루프(메인 또는 서브에이전트별)·모델별 토큰: 입력, 출력, 캐시 읽기, 캐시 쓰기, 요청 수
  • effort 단계별 횟수, 요청 시각(크기 제한 링 버퍼), 턴 시간
  • 엔진 비용 장부 스냅샷(세션 누적, API 환산 USD)
  • 서브에이전트: 선언 유형, 모델, 처음 본 시각, 지속 시간, spawn 시도 수
  • 마찰 카운터: 중단된 요청, 도구 오류, 거부된 도구, 압축
  • 도구·스킬 이름과 횟수, 세션 간 메시지의 출처 종류별 횟수
  • 지침 파일 지문(해시만: 경로 해시, 내용 해시, 크기)
  • /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.

요구사항

  • Claude Code 2.1.288 이상(mods는 얼리 액세스)
  • macOS, Windows, Linux
  • 리포트 명령에는 Python 3(설치할 패키지 없음). 없어도 기록기는 동작하고, 리포트 명령은 설치 안내만 출력한다.

설치

/plugin marketplace add matrix-freed/debrief
/plugin install debrief@debrief

Claude Code 2.1.275 이상에서는 한 줄로도 된다.

/plugin install debrief --marketplace matrix-freed/debrief

Claude에게 설치 맡기기

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으로 세고 추정으로 표시한다. 처리 토큰은 입력 + 캐시 쓰기 + 출력이고, 캐시 읽기는 따로 보여 준다.

프라이버시, 그리고 이것이 아닌 것

  • 기록은 본인 PC에만 남는다. 업로드도, 서버도 없다.
  • 사람별 순위나 비교는 없다. AAR은 프로젝트의 일하는 방식(시간과 토큰이 어디에 갔고 다음에 무엇을 바꿀지)을 다루며, 가설은 판정이 아니라 질문으로 쓴다.
  • 공유는 선택이다. 프로필 기본값은 shareConsent: false이고, 리포트에는 내보내기 허용 목록을 통과한 값만 들어간다.

상태

P0(API 실측), P1(기록기), P2(리포트 프로그램, 백필, 프로젝트 AAR) 완료. P0-RESULTS.md, P1-RESULTS.md, P2-RESULTS.md 참고. 첫 실데이터 AAR에서 확인한 알려진 갭:

  • mod를 켜기 전에 기록된 세션은 비용 장부가 없다(0으로 세고 추정 표시)
  • 자동 단발 SDK 세션은 간단한 규칙(진입점과 길이)으로 한 줄에 접는다
  • 실험용으로 띄운 자식 세션은 부모가 DEBRIEF_PARENT_SESSION을 넘기지 않으면 부모 표식이 없다
  • --from/--to 창은 세션을 고를 뿐 세션 안을 자르지 않는다
  • 백필의 열린 세션 판정은 파일 유휴 시간 기준이다

라이선스

MIT. LICENSE 참고.

Source 7 files
hooks/register.ts 712 lines
1import { 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}
712
hooks/insights.ts 54 lines
1import 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}
54
hooks/paths.ts 34 lines
1import 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')
34
hooks/profile.ts 81 lines
1import 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)
81
hooks/report.ts 104 lines
1import 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}
104
hooks/record.ts 335 lines
1import 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}
335
types/index.d.ts 158 lines
1// 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