diff --git "a/backlog/docs/doc-1 - \354\204\244\354\271\230-\352\260\200\354\235\264\353\223\234.md" "b/backlog/docs/doc-1 - \354\204\244\354\271\230-\352\260\200\354\235\264\353\223\234.md" index 8014ba9..d4a6147 100644 --- "a/backlog/docs/doc-1 - \354\204\244\354\271\230-\352\260\200\354\235\264\353\223\234.md" +++ "b/backlog/docs/doc-1 - \354\204\244\354\271\230-\352\260\200\354\235\264\353\223\234.md" @@ -3,7 +3,7 @@ id: doc-1 title: 설치 가이드 type: guide created_date: '2026-09-19 05:27' -updated_date: '2026-10-03 03:16' +updated_date: '2026-10-03 03:40' --- ## 🚀 설치 @@ -45,7 +45,7 @@ cd ~/my-project | 파일/디렉터리 | 위치 | 언제 | 비고 | | ------------------------- | ---------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------- | | `.gitformat-verified` | `<대상 저장소>/.git/` | `pre-commit` 통과 시 생성, `post-commit`이 곧 삭제 | 커밋 사이에 남지 않는 임시 마커 | -| `.gitformat-token-cursor` | `<대상 저장소>/.git/` | Claude Code 실측 성공 시에만 `post-commit`이 갱신(`unavailable`일 때는 갱신 안 함) | `Tokens-Used`/`Tool-Calls` 델타 계산용 커서(누적 줄 수), 커밋 간 유지됨 | +| `.gitformat-token-cursor` | `<대상 저장소>/.git/` | Claude Code 실측 성공 시에만 `post-commit`이 갱신(`unavailable`일 때는 갱신 안 함) | `Tokens-Used`/`Tool-Calls` 델타 계산용 커서(`<세션 ID> <줄 수>`, 세션이 바뀌면 0줄부터, GF-139), 커밋 간 유지됨 | | `template/hooks/*` | 이 git-logbook 클론 자신의 `template/` 안 | `install.sh --global` 실행 시 | 클론 위치를 가리키는 심볼릭 링크, 커밋 안 됨(`.gitignore`) | **커밋 자체가 바뀌는 경우**: `post-commit`이 조건에 따라 `git commit --amend`로 diff --git "a/backlog/docs/doc-16 - \355\206\240\355\201\260\302\267\355\210\264\354\275\234-\354\270\241\354\240\225-\353\260\251\353\262\225\352\263\274-\355\225\234\352\263\204.md" "b/backlog/docs/doc-16 - \355\206\240\355\201\260\302\267\355\210\264\354\275\234-\354\270\241\354\240\225-\353\260\251\353\262\225\352\263\274-\355\225\234\352\263\204.md" index d762161..57f4c69 100644 --- "a/backlog/docs/doc-16 - \355\206\240\355\201\260\302\267\355\210\264\354\275\234-\354\270\241\354\240\225-\353\260\251\353\262\225\352\263\274-\355\225\234\352\263\204.md" +++ "b/backlog/docs/doc-16 - \355\206\240\355\201\260\302\267\355\210\264\354\275\234-\354\270\241\354\240\225-\353\260\251\353\262\225\352\263\274-\355\225\234\352\263\204.md" @@ -3,7 +3,7 @@ id: doc-16 title: 토큰·툴콜 측정 방법과 한계 type: specification created_date: '2026-09-25 19:34' -updated_date: '2026-09-25 19:46' +updated_date: '2026-10-03 03:39' --- # 토큰·툴콜 측정 방법과 한계 @@ -33,6 +33,26 @@ apiBlockIndex=3 content=['tool_use'] usage합=87932 3. 그룹의 `tool_use` 블록은 **모든 줄에서 모은다**(한 응답의 도구 호출이 여러 줄에 흩어져 있다). 이 목록으로 귀속 여부를 판정한다 +## 현재 post-commit 구현 (GF-139 핫픽스) + +아래 "귀속 필터"와 "기록 형식"은 GF-129가 `prepare-commit-msg`로 옮기며 들일 목표 설계다. +지금 `post-commit`은 귀속 필터 없이 직전 커밋 이후 구간 전체를 합산해 `Tokens-Used: ` +한 값으로 남긴다. 다만 두 가지는 GF-139에서 먼저 고쳤다. + +- **응답 단위 계상.** 위 알고리즘 1·2단계를 그대로 적용한다. `(requestId, message.id)`가 + 같은 assistant 줄은 `usage`를 한 번만 더하고, 둘 중 하나라도 없는 줄은 줄 단위로 더한다. + `Tool-Calls`는 모든 줄의 `tool_use` 블록을 센다. +- **세션별 커서.** `.git/.gitformat-token-cursor`는 `<세션 ID> <줄 수>` 한 줄이다(개행 없음). + 트랜스크립트는 세션마다 새 파일이라 줄 수만 저장하면 새 세션의 짧은 파일을 이전 세션의 + 줄 수로 읽어 첫 커밋이 0이 됐다. 세션 ID가 다르거나, 숫자만 든 예전 형식이거나, 손상됐으면 + 0줄부터 센다. +- **커밋 경계를 걸친 응답.** 응답 하나의 줄이 커서 앞뒤로 나뉘면 앞 커밋이 이미 그 `usage`를 + 더했다. 그래서 같은 세션이면 커서 앞 구간의 응답 키를 먼저 모아 두고, 뒤 커밋에서는 그 + 키의 `usage`를 다시 더하지 않는다. + +형식 변경(`in`/`out`/`delta`)과 귀속 필터는 GF-129에 남아 있다. GF-129는 위 세 가지를 +`prepare-commit-msg`로 옮길 때 함께 가져가야 한다. + ## 귀속 필터 쌓인 턴 중 **이 커밋과 관계있는 턴만 골라내는 체**다. 기준은 하나 — 그 턴의 도구 호출이 diff --git "a/backlog/drafts/draft-22 - Tokens-Used-\355\225\253\355\224\275\354\212\244-\342\200\224-\354\204\270\354\205\230\353\263\204-\354\273\244\354\204\234\354\231\200-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260.md" "b/backlog/drafts/draft-22 - Tokens-Used-\355\225\253\355\224\275\354\212\244-\342\200\224-\354\204\270\354\205\230\353\263\204-\354\273\244\354\204\234\354\231\200-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260.md" deleted file mode 100644 index 7c532a8..0000000 --- "a/backlog/drafts/draft-22 - Tokens-Used-\355\225\253\355\224\275\354\212\244-\342\200\224-\354\204\270\354\205\230\353\263\204-\354\273\244\354\204\234\354\231\200-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260.md" +++ /dev/null @@ -1,44 +0,0 @@ ---- -id: DRAFT-22 -title: Tokens-Used 핫픽스 — 세션별 커서와 응답 단위 중복 제거 -status: Draft -assignee: [] -created_date: '2026-10-03 03:30' -updated_date: '2026-10-03 03:31' -labels: - - hooks - - bug -dependencies: [] -priority: high -type: bug ---- - -## Description - - -Tokens-Used 값이 두 방향으로 틀리고 있다(2026-10-03 실측). - -첫째, 세션이 바뀌면 첫 커밋이 0으로 찍힌다. post-commit은 트랜스크립트에서 이미 읽은 줄 수를 저장소에 커서 하나(.git/.gitformat-token-cursor)로만 저장하는데, 트랜스크립트는 세션마다 새 파일이다. 새 세션의 짧은 파일을 이전 세션의 줄 수(927)로 읽으면 새 줄이 없다고 판단해 0이 된다. 커밋 7fef01e가 이렇게 0으로 남았다. GF-129에 없는 버그다. - -둘째, 0이 아닐 때는 2~3배 부풀려진다. 트랜스크립트는 API 응답 하나를 content 블록마다 한 줄씩 기록하고, 각 줄이 같은 usage를 반복해서 들고 있다. 지금 구현은 줄마다 더한다. 이번 세션 실측으로 assistant 215줄이 응답 109개였다. 이건 GF-129 AC#1과 같은 문제다. - -GF-129는 GF-127→GF-128 재설계 뒤로 묶여 있어서, 값이 틀린 채로 커밋이 계속 쌓인다. 그래서 지금 post-commit에서 두 버그만 먼저 고친다. Tokens-Used 형식 변경(in/out/delta)과 커밋 귀속은 GF-129에 그대로 남긴다. - - -## Acceptance Criteria - -- [ ] #1 커서 파일에 세션 ID와 줄 수를 함께 저장하고, 세션 ID가 다르면 트랜스크립트 0줄부터 집계한다 -- [ ] #2 예전 형식(숫자만) 커서는 세션 불일치로 취급해 0줄부터 집계한다 -- [ ] #3 (requestId, message.id)가 같은 assistant 줄은 usage를 한 번만 더한다 -- [ ] #4 requestId나 message.id가 없는 줄은 지금처럼 줄 단위로 더한다 -- [ ] #5 Tool-Calls는 지금처럼 모든 줄의 tool_use 블록을 센다 -- [ ] #6 새 세션 첫 커밋, 중복 줄, 예전 커서 형식을 다루는 테스트를 추가하고 기존 테스트의 커서 기대값을 새 형식에 맞춘다 -- [ ] #7 doc-16에 세션별 커서와 응답 단위 계상을 반영하고, GF-129 AC#1이 이 핫픽스로 선반영됐다고 코멘트를 남긴다 - - -## Definition of Done - -- [ ] #1 python3 -m unittest discover -s tests 전체 통과 -- [ ] #2 ruff check 통과 -- [ ] #3 실제 커밋의 Tokens-Used가 트랜스크립트를 응답 단위로 따로 합산한 값과 일치하는지 확인 - diff --git "a/backlog/tasks/gf-129 - \355\206\240\355\201\260\302\267\355\210\264\354\275\234-\354\270\241\354\240\225\354\235\204-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260\354\231\200-\354\273\244\353\260\213-\352\267\200\354\206\215\354\234\274\353\241\234-\354\236\254\354\236\221\354\204\261.md" "b/backlog/tasks/gf-129 - \355\206\240\355\201\260\302\267\355\210\264\354\275\234-\354\270\241\354\240\225\354\235\204-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260\354\231\200-\354\273\244\353\260\213-\352\267\200\354\206\215\354\234\274\353\241\234-\354\236\254\354\236\221\354\204\261.md" index c041f55..e8bd9ff 100644 --- "a/backlog/tasks/gf-129 - \355\206\240\355\201\260\302\267\355\210\264\354\275\234-\354\270\241\354\240\225\354\235\204-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260\354\231\200-\354\273\244\353\260\213-\352\267\200\354\206\215\354\234\274\353\241\234-\354\236\254\354\236\221\354\204\261.md" +++ "b/backlog/tasks/gf-129 - \355\206\240\355\201\260\302\267\355\210\264\354\275\234-\354\270\241\354\240\225\354\235\204-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260\354\231\200-\354\273\244\353\260\213-\352\267\200\354\206\215\354\234\274\353\241\234-\354\236\254\354\236\221\354\204\261.md" @@ -4,7 +4,7 @@ title: 토큰·툴콜 측정을 응답 단위 중복 제거와 커밋 귀속으 status: To Do assignee: [] created_date: '2026-09-25 19:33' -updated_date: '2026-09-25 21:43' +updated_date: '2026-10-03 03:39' labels: - hooks - trailers @@ -58,3 +58,13 @@ type: bug - [ ] #2 ruff check 통과 - [ ] #3 이 저장소 자신의 커밋이 새 훅으로 정상 생성되는지 확인 + +## Comments + + +author: @claude +created: 2026-10-03 03:39 +--- +GF-139 핫픽스로 AC#1(응답 단위 계상: (requestId, message.id)가 같은 assistant 줄은 usage를 한 번만 더함)을 post-commit에 먼저 적용했다. 세션별 커서(.git/.gitformat-token-cursor = '<세션 ID> <줄 수>', 세션 불일치·예전 숫자 형식이면 0줄부터)와, 커서 경계를 걸친 응답의 중복 방지(커서 앞 구간의 응답 키를 먼저 모음)도 함께 들어갔다. 이 작업을 prepare-commit-msg로 옮길 때 세 가지를 모두 가져가야 한다. 형식 변경(in/out/delta)과 귀속 필터는 그대로 GF-129 범위다. 상세는 doc-16 '현재 post-commit 구현' 절. +--- + diff --git "a/backlog/tasks/gf-139 - Tokens-Used-\355\225\253\355\224\275\354\212\244-\342\200\224-\354\204\270\354\205\230\353\263\204-\354\273\244\354\204\234\354\231\200-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260.md" "b/backlog/tasks/gf-139 - Tokens-Used-\355\225\253\355\224\275\354\212\244-\342\200\224-\354\204\270\354\205\230\353\263\204-\354\273\244\354\204\234\354\231\200-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260.md" new file mode 100644 index 0000000..8205782 --- /dev/null +++ "b/backlog/tasks/gf-139 - Tokens-Used-\355\225\253\355\224\275\354\212\244-\342\200\224-\354\204\270\354\205\230\353\263\204-\354\273\244\354\204\234\354\231\200-\354\235\221\353\213\265-\353\213\250\354\234\204-\354\244\221\353\263\265-\354\240\234\352\261\260.md" @@ -0,0 +1,80 @@ +--- +id: GF-139 +title: Tokens-Used 핫픽스 — 세션별 커서와 응답 단위 중복 제거 +status: Done +assignee: + - '@claude' +created_date: '2026-10-03 03:30' +updated_date: '2026-10-03 03:42' +labels: + - hooks + - bug +dependencies: [] +references: + - decision-5 + - decision-19 +documentation: + - backlog/docs/doc-16 - 토큰·툴콜-측정-방법과-한계.md +priority: high +type: bug +--- + +## Description + + +Tokens-Used 값이 두 방향으로 틀리고 있다(2026-10-03 실측). + +첫째, 세션이 바뀌면 첫 커밋이 0으로 찍힌다. post-commit은 트랜스크립트에서 이미 읽은 줄 수를 저장소에 커서 하나(.git/.gitformat-token-cursor)로만 저장하는데, 트랜스크립트는 세션마다 새 파일이다. 새 세션의 짧은 파일을 이전 세션의 줄 수(927)로 읽으면 새 줄이 없다고 판단해 0이 된다. 커밋 7fef01e가 이렇게 0으로 남았다. GF-129에 없는 버그다. + +둘째, 0이 아닐 때는 2~3배 부풀려진다. 트랜스크립트는 API 응답 하나를 content 블록마다 한 줄씩 기록하고, 각 줄이 같은 usage를 반복해서 들고 있다. 지금 구현은 줄마다 더한다. 이번 세션 실측으로 assistant 215줄이 응답 109개였다. 이건 GF-129 AC#1과 같은 문제다. + +GF-129는 GF-127→GF-128 재설계 뒤로 묶여 있어서, 값이 틀린 채로 커밋이 계속 쌓인다. 그래서 지금 post-commit에서 두 버그만 먼저 고친다. Tokens-Used 형식 변경(in/out/delta)과 커밋 귀속은 GF-129에 그대로 남긴다. + + +## Acceptance Criteria + +- [x] #1 커서 파일에 세션 ID와 줄 수를 함께 저장하고, 세션 ID가 다르면 트랜스크립트 0줄부터 집계한다 +- [x] #2 예전 형식(숫자만) 커서는 세션 불일치로 취급해 0줄부터 집계한다 +- [x] #3 (requestId, message.id)가 같은 assistant 줄은 usage를 한 번만 더한다 +- [x] #4 requestId나 message.id가 없는 줄은 지금처럼 줄 단위로 더한다 +- [x] #5 Tool-Calls는 지금처럼 모든 줄의 tool_use 블록을 센다 +- [x] #6 새 세션 첫 커밋, 중복 줄, 예전 커서 형식을 다루는 테스트를 추가하고 기존 테스트의 커서 기대값을 새 형식에 맞춘다 +- [x] #7 doc-16에 세션별 커서와 응답 단위 계상을 반영하고, GF-129 AC#1이 이 핫픽스로 선반영됐다고 코멘트를 남긴다 + + +## Definition of Done + +- [x] #1 python3 -m unittest discover -s tests 전체 통과 +- [x] #2 ruff check 통과 +- [x] #3 실제 커밋의 Tokens-Used가 트랜스크립트를 응답 단위로 따로 합산한 값과 일치하는지 확인 + + +## Implementation Plan + + +1. 테스트 먼저(TDD): 새 세션 첫 커밋, 같은 (requestId, message.id) 줄 1회 계상, 키 없는 줄 줄 단위, 예전 숫자 커서, 커서 경계를 걸친 응답 중복 방지 +2. hooks/post-commit의 measure_claude_code_token_usage()/aggregate_usage() 수정 — 커서 형식 ' <줄 수>' +3. 기존 테스트의 커서 기대값 갱신 +4. doc-16 갱신, GF-129 코멘트 +5. 실제 커밋으로 값 대조 (구현은 서브에이전트, 검토·커밋 확인은 메인) + + +## Implementation Notes + + +구현(서브에이전트, TDD): +- 테스트 먼저(a096f1d): 새 세션 첫 커밋 / 예전 숫자 커서 / 같은 (requestId, message.id) 줄 1회 계상 / id 없는 줄 줄 단위 / 커서 경계를 걸친 응답 중복 방지 5건 추가, 기존 커서 기대값을 'fake-session '으로 갱신. 구현 전 실행 시 11건 중 8건 실패 확인. +- 구현(482fd24): hooks/post-commit의 aggregate_usage(lines, seen_keys)와 measure_claude_code_token_usage()만 수정. 커서 형식 '<세션 ID> <줄 수>'(개행 없음), 마지막 공백 기준 rsplit로 파싱. 세션 불일치·숫자만·손상 → 0줄부터. 같은 세션이면 커서 앞 구간을 aggregate_usage로 돌려 응답 키만 모으고(합산은 버림) 새 구간에서 그 키의 usage를 건너뜀. 앞 구간 파싱 실패도 transcript-parse-failed로 배치 실패 처리. 같은 응답의 usage는 첫 줄 값을 씀. +- 결과: python3 -m unittest discover -s tests → Ran 98 tests, OK. ruff check hooks/ tests/ + python 훅 → All checks passed. +- 실커밋 482fd24의 커서: '<세션 UUID> 1353' 형식으로 기록됨(Tokens-Used: 25915492, 이 값은 이번 세션의 첫 커밋 이후 구간 전체 합). DoD#3(응답 단위 별도 합산과 대조)은 메인에서 확인 필요. +- doc-16에 '현재 post-commit 구현 (GF-139 핫픽스)' 절 추가, GF-129에 선반영 코멘트 남김. +- 범위 밖 발견: doc-1 설치 가이드 표의 .gitformat-token-cursor 설명이 '(누적 줄 수)'로 남아 있음 — 손대지 않음. + +메인 검증: 코드 리뷰(aggregate_usage·measure_claude_code_token_usage 두 함수만 변경, 실패 처리 유지). GF-139 테스트 5건 포함 전체 98개 통과, ruff 통과. DoD#3: 커밋 8ffc7ee의 커서 구간(1353→1391줄)을 트랜스크립트에서 응답 단위로 따로 합산 = 813,177 토큰 / 3 호출, 트레일러 값과 일치(줄 단위였다면 1,893,217). 서브에이전트 커밋 482fd24도 서브에이전트가 같은 방식으로 일치 확인(25,915,492). doc-1의 커서 설명도 새 형식으로 갱신. 알려진 한계: 서브에이전트의 토큰은 부모 트랜스크립트에 없어 서브에이전트가 만든 커밋은 0으로 남는다(doc-16에 기존 기재). + + +## Final Summary + + +post-commit의 Tokens-Used 측정에서 두 버그를 고쳤다. 커서를 '<세션 ID> <줄 수>'로 저장해 새 세션 첫 커밋이 0이 되던 문제를 없앴고, (requestId, message.id) 단위로 usage를 한 번만 더해 2~3배 과대계상을 없앴다(커밋 경계를 걸친 응답 포함). 테스트 5건 추가, 전체 98개·ruff 통과, 실제 커밋 8ffc7ee 값이 독립 합산과 일치. 형식 변경과 커밋 귀속은 GF-129에 남김. + diff --git "a/backlog/drafts/draft-23 - git-trail\353\241\234-\352\260\234\353\252\205\355\225\230\352\263\240-\352\260\234\353\205\220-\354\247\200\353\217\204-\353\254\270\354\204\234\353\245\274-\353\247\214\353\223\240\353\213\244.md" "b/backlog/tasks/gf-140 - git-trail\353\241\234-\352\260\234\353\252\205\355\225\230\352\263\240-\352\260\234\353\205\220-\354\247\200\353\217\204-\353\254\270\354\204\234\353\245\274-\353\247\214\353\223\240\353\213\244.md" similarity index 90% rename from "backlog/drafts/draft-23 - git-trail\353\241\234-\352\260\234\353\252\205\355\225\230\352\263\240-\352\260\234\353\205\220-\354\247\200\353\217\204-\353\254\270\354\204\234\353\245\274-\353\247\214\353\223\240\353\213\244.md" rename to "backlog/tasks/gf-140 - git-trail\353\241\234-\352\260\234\353\252\205\355\225\230\352\263\240-\352\260\234\353\205\220-\354\247\200\353\217\204-\353\254\270\354\204\234\353\245\274-\353\247\214\353\223\240\353\213\244.md" index 6e5b703..f82c740 100644 --- "a/backlog/drafts/draft-23 - git-trail\353\241\234-\352\260\234\353\252\205\355\225\230\352\263\240-\352\260\234\353\205\220-\354\247\200\353\217\204-\353\254\270\354\204\234\353\245\274-\353\247\214\353\223\240\353\213\244.md" +++ "b/backlog/tasks/gf-140 - git-trail\353\241\234-\352\260\234\353\252\205\355\225\230\352\263\240-\352\260\234\353\205\220-\354\247\200\353\217\204-\353\254\270\354\204\234\353\245\274-\353\247\214\353\223\240\353\213\244.md" @@ -1,14 +1,20 @@ --- -id: DRAFT-23 +id: GF-140 title: git-trail로 개명하고 개념 지도 문서를 만든다 -status: Draft +status: To Do assignee: [] created_date: '2026-10-03 03:31' -updated_date: '2026-10-03 03:31' +updated_date: '2026-10-03 03:35' labels: - docs - naming dependencies: [] +references: + - decision-25 + - decision-24 +documentation: + - backlog/docs/doc-3 - AI-귀속-트레일러-레퍼런스.md + - backlog/docs/doc-20 - GitHub-원격-강제-설정-관리-—-rulesets와-저장소-설정.md priority: medium type: docs --- diff --git a/hooks/post-commit b/hooks/post-commit index f406e36..91e5530 100755 --- a/hooks/post-commit +++ b/hooks/post-commit @@ -86,7 +86,10 @@ def local_get(key): MARKER = os.path.join(GIT_DIR, conf_get("gitformat.markerFile")) # Tokens-Used/Tool-Calls 델타 커서(decision-5 확장). .gitformat-verified와 같은 # "커밋 간 git-미추적 상태 파일" 관례를 따르되, 이 파일은 삭제되지 않고 -# measure_claude_code_token_usage()가 매번 최신 트랜스크립트 줄 수로 덮어쓴다. +# measure_claude_code_token_usage()가 매번 "<세션 ID> <최신 트랜스크립트 줄 수>" +# 한 줄로 덮어쓴다. 트랜스크립트는 세션마다 새 파일이라 줄 수만 저장하면 새 세션의 +# 짧은 파일을 이전 세션의 줄 수로 읽어 첫 커밋이 0이 된다 - 그래서 세션 ID를 같이 +# 저장하고, 다르면 0줄부터 다시 센다(GF-139). # 파일명은 gitformat.conf로 빼지 않는다 - 컨슈머가 오버라이드할 이유가 없는 순수 # 내부 구현 세부사항이라 다른 trailer 키 이름들과 성격이 다르다. CURSOR = os.path.join(GIT_DIR, ".gitformat-token-cursor") @@ -326,7 +329,16 @@ USAGE_FIELDS = ( # assistant 턴의 message.usage를 합산하고 tool_use 콘텐츠 블록을 센다. 줄 하나라도 # JSON 파싱에 실패하면 ValueError를 그대로 올려 배치 전체를 실패시킨다 — 불량 줄만 # 건너뛰면 "일부만 집계된 값"이 정상값인 척 기록되므로 동작 변경이다. -def aggregate_usage(lines): +# +# 트랜스크립트는 API 응답 하나를 content 블록마다 한 줄씩 기록하고, 각 줄이 같은 +# message.usage를 반복해서 들고 있다 - 줄마다 더하면 2~3배 부풀려진다(GF-139 실측: +# assistant 215줄이 응답 109개). 같은 응답의 줄은 최상위 requestId와 message.id를 +# 공유하므로 이 쌍을 응답 키로 삼아 usage는 키당 한 번만 더한다. seen_keys는 호출자가 +# 넘기는 집합으로, 여기서 본 키를 채워 넣는다 - 커서 앞 구간의 키를 미리 채워 넘기면 +# 커밋 경계를 걸친 응답이 뒤 커밋에서 다시 더해지지 않는다. 둘 중 하나라도 없는 줄은 +# 같은 응답인지 판단할 근거가 없으므로 예전처럼 줄 단위로 더한다. tool_use는 블록이 +# 줄마다 다르므로 키와 무관하게 모든 줄에서 센다. +def aggregate_usage(lines, seen_keys): tokens = 0 calls = 0 for line in lines: @@ -338,7 +350,16 @@ def aggregate_usage(lines): message = entry.get("message") if not isinstance(message, dict): continue + request_id = entry.get("requestId") + message_id = message.get("id") + response_key = None + if isinstance(request_id, str) and request_id and isinstance(message_id, str) and message_id: + response_key = (request_id, message_id) usage = message.get("usage") + if response_key is not None and response_key in seen_keys: + usage = None + elif response_key is not None: + seen_keys.add(response_key) if isinstance(usage, dict): for field in USAGE_FIELDS: value = usage.get(field) @@ -356,8 +377,9 @@ def aggregate_usage(lines): # Tokens-Used/Tool-Calls(decision-5 확장, GF-97 amendment): trailer_ai_model()과 # 같은 트랜스크립트를 message.usage(Anthropic API 응답을 그대로 기록한 값이라 # 자가신고가 아님) 합산과 tool_use 개수 집계에 쓴다. 한 세션에서 커밋이 여러 번 -# 나오므로 세션 누적치를 매번 반복하면 의미가 없어, CURSOR 파일에 "이미 처리한 -# 트랜스크립트 줄 수"를 저장해두고 그 이후 구간만 델타로 집계한다. +# 나오므로 세션 누적치를 매번 반복하면 의미가 없어, CURSOR 파일에 "세션 ID와 그 +# 세션 트랜스크립트에서 이미 처리한 줄 수"를 저장해두고 그 이후 구간만 델타로 +# 집계한다. usage는 줄이 아니라 응답 단위로 한 번씩 더한다(GF-139, aggregate_usage()). # # 성공하면 MEASUREMENT_STATUS=ok와 TOKENS_SUM/TOOL_CALL_COUNT를 채운다. 실패하면 # MEASUREMENT_STATUS=unavailable + MEASUREMENT_REASON(실패 지점을 가리키는 짧은 @@ -402,32 +424,47 @@ def measure_claude_code_token_usage(): if lines and lines[-1] == "": lines.pop() - # 커서 파일이 없거나(최초 실행) 손상됐으면 0으로 취급 - 이건 조회 자체의 - # 실패가 아니라 "아직 아무 줄도 처리한 적 없음"과 동치이므로 실패로 취급하지 않는다. + # 커서는 "<세션 ID> <줄 수>" 한 줄이다(GF-139). 파일이 없거나(최초 실행) + # 손상됐거나, 숫자만 든 예전 형식이거나, 세션 ID가 지금 세션과 다르면 0으로 + # 취급한다 - 이건 조회 자체의 실패가 아니라 "이 세션의 트랜스크립트는 아직 아무 + # 줄도 처리한 적 없음"과 동치이므로 실패로 취급하지 않는다. 세션 ID는 마지막 + # 공백 기준으로 잘라 비교한다(줄 수 쪽엔 공백이 없다). + session_id = os.environ["CLAUDE_CODE_SESSION_ID"] cursor_count = 0 try: with open(CURSOR, "rb") as f: raw_cursor = f.read().decode("utf-8", errors="replace").rstrip("\n") except OSError: raw_cursor = "" - if re.fullmatch(r"[0-9]+", raw_cursor): - cursor_count = int(raw_cursor) + cursor_parts = raw_cursor.rsplit(" ", 1) + if ( + len(cursor_parts) == 2 + and cursor_parts[0] == session_id + and re.fullmatch(r"[0-9]+", cursor_parts[1]) + ): + cursor_count = int(cursor_parts[1]) new_entries = lines[cursor_count:] if total_lines > cursor_count else [] # 입력 전체를 한 번에 슬러프해 처리하던 동작과 같은 실패 단위를 유지한다 - 한 # 줄이라도 깨지면 배치 전체가 실패해 사유 슬러그만 남고 커서는 갱신되지 않는다. + # 커서 앞 구간(같은 세션에서 이미 집계한 줄)은 합산하지 않고 응답 키만 모은다 - + # 응답 하나의 줄이 커밋 경계 앞뒤로 나뉘면 앞 커밋이 이미 그 usage를 더했다. + # 이 구간은 앞 커밋이 이미 파싱에 성공한 줄이므로 여기서 깨지면 트랜스크립트가 + # 바뀐 것이고, 새 구간과 똑같이 배치 실패로 다룬다. + seen_keys = set() try: - TOKENS_SUM, TOOL_CALL_COUNT = aggregate_usage(new_entries) + aggregate_usage(lines[:cursor_count], seen_keys) + TOKENS_SUM, TOOL_CALL_COUNT = aggregate_usage(new_entries, seen_keys) except (ValueError, TypeError): MEASUREMENT_REASON = "transcript-parse-failed" return # 여기까지 왔으면 트랜스크립트를 실제로 읽어 처리했으므로, 합산값이 0이어도 # 커서는 항상 갱신한다 - 그래야 다음 커밋이 같은 구간을 중복 집계하지 않는다. - # 개행 없이 숫자만 쓴다(읽는 쪽과 포맷을 맞춘다). + # 개행 없이 "<세션 ID> <줄 수>"만 쓴다(읽는 쪽과 포맷을 맞춘다). with open(CURSOR, "w", encoding="utf-8") as f: - f.write(str(total_lines)) + f.write(f"{session_id} {total_lines}") MEASUREMENT_STATUS = "ok" diff --git a/tests/test_token_usage_measurement.py b/tests/test_token_usage_measurement.py index 96050df..6e71cff 100644 --- a/tests/test_token_usage_measurement.py +++ b/tests/test_token_usage_measurement.py @@ -27,6 +27,27 @@ def assistant(input_tokens, output_tokens, tool_names=()): } +def response_line(request_id, message_id, input_tokens, output_tokens, block): + """API 응답 하나가 content 블록마다 한 줄씩 기록되는 실제 트랜스크립트 모양. + + 같은 응답의 줄들은 최상위 requestId와 message.id를 공유하고 usage도 똑같이 + 반복하며, content만 블록별로 다르다(GF-139 실측). + """ + return { + "type": "assistant", + "requestId": request_id, + "message": { + "id": message_id, + "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, + "content": [block], + }, + } + + +TEXT_BLOCK = {"type": "text", "text": "hi"} +TOOL_BLOCK = {"type": "tool_use", "name": "bash"} + + class TokenUsageMeasurementTest(IsolatedRepoTestCase): def cursor_value(self): return self.git_dir_file(CURSOR_NAME).read_text(encoding="utf-8").strip() @@ -56,7 +77,7 @@ def test_알려진_usage_값이_정확히_합산된다(self): message = self.head_message() self.assertTrailerCount(message, "Tokens-Used: 45", 1) self.assertTrailerCount(message, "Tool-Calls: 2", 1) - self.assertEqual("3", self.cursor_value()) + self.assertEqual("fake-session 3", self.cursor_value()) def test_두_번째_커밋은_새로_추가된_줄만_집계한다(self): """[GF-96] 같은 세션의 두 번째 커밋은 첫 커밋 이후 새로 추가된 줄만 델타로 집계한다""" @@ -77,7 +98,7 @@ def test_두_번째_커밋은_새로_추가된_줄만_집계한다(self): self.assertTrailerCount(message, "Tool-Calls: 1", 1) # 누적 합(21)이 아니라 델타(6)여야 한다. self.assertTrailerCount(message, "Tokens-Used: 21", 0) - self.assertEqual("3", self.cursor_value()) + self.assertEqual("fake-session 3", self.cursor_value()) def test_트랜스크립트_파일이_없으면_사유가_명시된다(self): """[GF-97] 트랜스크립트 파일이 없으면 Tokens-Used/Tool-Calls가 unavailable (transcript-not-found)로 명시된다""" @@ -130,7 +151,7 @@ def test_실측_0은_unavailable이_아니라_0으로_명시된다(self): self.assertTrailerCount(message, "Tokens-Used: 0", 1) self.assertTrailerCount(message, "Tool-Calls: 0", 1) self.assertNotIn("unavailable", message) - self.assertEqual("1", self.cursor_value()) + self.assertEqual("fake-session 1", self.cursor_value()) def test_깨진_줄이_있으면_배치_전체가_실패하고_커서는_그대로다(self): """[GF-112] 새 구간에 깨진 줄이 하나라도 있으면 배치 전체가 unavailable (transcript-parse-failed)이고 커서는 그대로다""" @@ -143,7 +164,7 @@ def test_깨진_줄이_있으면_배치_전체가_실패하고_커서는_그대 self.commit("[feat] parse failure baseline commit", env=env) ) self.assertTrailerCount(self.head_message(), "Tokens-Used: 15", 1) - self.assertEqual("1", self.cursor_value()) + self.assertEqual("fake-session 1", self.cursor_value()) # 새 구간에 정상 줄과 깨진 줄을 함께 넣는다 — 깨진 줄만 건너뛰고 정상 줄을 # 집계하면 "일부만 집계된 값"이 정상값인 척 기록되므로, 배치 전체가 실패해야 @@ -163,7 +184,116 @@ def test_깨진_줄이_있으면_배치_전체가_실패하고_커서는_그대 ) self.assertTrailerCount(message, "Tokens-Used: 10", 0) # 커서가 그대로라야 다음 커밋이 같은 구간을 다시 시도한다. - self.assertEqual("1", self.cursor_value()) + self.assertEqual("fake-session 1", self.cursor_value()) + + def test_다른_세션의_커서가_있어도_새_세션_첫_커밋을_집계한다(self): + """[GF-139] 다른 세션이 남긴 커서가 있어도 새 세션의 첫 커밋은 트랜스크립트 0줄부터 집계한다""" + home = self.fake_home() + # 이전 세션의 긴 트랜스크립트가 커서를 3으로 남긴다. + self.write_transcript( + home, [assistant(1, 1), assistant(1, 1), assistant(1, 1)], session_id="old" + ) + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + self.assertAccepted( + self.commit( + "[feat] old session commit", + env=self.claude_env(home, session_id="old"), + ) + ) + self.assertEqual("old 3", self.cursor_value()) + + # 새 세션의 트랜스크립트는 커서(3)보다 짧다 - 줄 수만 비교하면 0이 된다. + self.write_transcript(home, [assistant(10, 5, ("bash",))], session_id="new") + self.write("b.txt", "bye\n") + self.git_ok("add", "b.txt") + self.assertAccepted( + self.commit( + "[feat] new session commit", + env=self.claude_env(home, session_id="new"), + ) + ) + message = self.head_message() + self.assertTrailerCount(message, "Tokens-Used: 15", 1) + self.assertTrailerCount(message, "Tool-Calls: 1", 1) + self.assertEqual("new 1", self.cursor_value()) + + def test_예전_숫자_커서는_세션_불일치로_취급한다(self): + """[GF-139] 숫자만 담긴 예전 형식 커서는 세션 불일치로 취급해 0줄부터 집계한다""" + home = self.fake_home() + self.write_transcript(home, [assistant(10, 5), assistant(1, 2)]) + self.git_dir_file(CURSOR_NAME).write_text("1", encoding="utf-8") + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + self.assertAccepted( + self.commit("[feat] legacy cursor commit", env=self.claude_env(home)) + ) + self.assertTrailerCount(self.head_message(), "Tokens-Used: 18", 1) + self.assertEqual("fake-session 2", self.cursor_value()) + + def test_같은_응답의_여러_줄은_usage를_한_번만_더한다(self): + """[GF-139] (requestId, message.id)가 같은 assistant 줄은 usage를 한 번만 더하고 tool_use는 줄마다 센다""" + home = self.fake_home() + self.write_transcript( + home, + [ + response_line("req-1", "msg-1", 100, 10, TEXT_BLOCK), + response_line("req-1", "msg-1", 100, 10, TOOL_BLOCK), + response_line("req-1", "msg-1", 100, 10, TOOL_BLOCK), + response_line("req-2", "msg-2", 20, 2, TOOL_BLOCK), + ], + ) + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + self.assertAccepted( + self.commit("[feat] dedup response lines", env=self.claude_env(home)) + ) + message = self.head_message() + self.assertTrailerCount(message, "Tokens-Used: 132", 1) + self.assertTrailerCount(message, "Tool-Calls: 3", 1) + + def test_id가_없는_줄은_줄_단위로_더한다(self): + """[GF-139] requestId나 message.id가 없는 줄은 같은 usage라도 줄마다 더한다""" + home = self.fake_home() + no_request_id = response_line("req-1", "msg-1", 7, 3, TEXT_BLOCK) + del no_request_id["requestId"] + no_message_id = response_line("req-1", "msg-1", 7, 3, TEXT_BLOCK) + del no_message_id["message"]["id"] + self.write_transcript( + home, + [no_request_id, dict(no_request_id), no_message_id, assistant(7, 3)], + ) + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + self.assertAccepted( + self.commit("[feat] lines without ids", env=self.claude_env(home)) + ) + self.assertTrailerCount(self.head_message(), "Tokens-Used: 40", 1) + + def test_커서_경계를_걸친_응답은_두_커밋에_중복_계상되지_않는다(self): + """[GF-139] 한 응답의 줄이 커밋 경계 앞뒤로 나뉘면 뒤 커밋은 그 usage를 다시 더하지 않는다""" + home = self.fake_home() + env = self.claude_env(home) + self.write_transcript(home, [response_line("req-1", "msg-1", 100, 10, TEXT_BLOCK)]) + self.write("a.txt", "hi\n") + self.git_ok("add", "a.txt") + self.assertAccepted(self.commit("[feat] straddle commit one", env=env)) + self.assertTrailerCount(self.head_message(), "Tokens-Used: 110", 1) + + self.write_transcript( + home, + [ + response_line("req-1", "msg-1", 100, 10, TOOL_BLOCK), + response_line("req-2", "msg-2", 5, 1, TEXT_BLOCK), + ], + ) + self.write("b.txt", "bye\n") + self.git_ok("add", "b.txt") + self.assertAccepted(self.commit("[feat] straddle commit two", env=env)) + message = self.head_message() + self.assertTrailerCount(message, "Tokens-Used: 6", 1) + self.assertTrailerCount(message, "Tool-Calls: 1", 1) + self.assertEqual("fake-session 3", self.cursor_value()) if __name__ == "__main__":