From 8be6cb3fffeb07f8a6f7c7565d71937b43b6d76c Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:20:33 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[docs][backlog]=20decision-27=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EB=8B=B9=20=ED=86=A0=ED=81=B0=EB=9F=89,=20GF-129=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: 3185678 Tool-Calls: 10 Hooks-Commit: cac922d Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- ...0\355\225\255-\353\214\200\354\262\264.md" | 40 +++++++++++++++ ...4-\354\236\254\354\236\221\354\204\261.md" | 50 +++++++++++++------ 2 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 "backlog/decisions/decision-27 - Tokens-Used\353\212\224-\354\273\244\353\260\213\353\213\271-\355\206\240\355\201\260\353\237\211\354\235\264\353\213\244-\342\200\224-\353\215\270\355\203\200\353\245\274-\352\270\260\353\241\235\355\225\230\354\247\200-\354\225\212\353\212\224\353\213\244-decision-19\354\235\230-delta-\354\241\260\355\225\255-\353\214\200\354\262\264.md" diff --git "a/backlog/decisions/decision-27 - Tokens-Used\353\212\224-\354\273\244\353\260\213\353\213\271-\355\206\240\355\201\260\353\237\211\354\235\264\353\213\244-\342\200\224-\353\215\270\355\203\200\353\245\274-\352\270\260\353\241\235\355\225\230\354\247\200-\354\225\212\353\212\224\353\213\244-decision-19\354\235\230-delta-\354\241\260\355\225\255-\353\214\200\354\262\264.md" "b/backlog/decisions/decision-27 - Tokens-Used\353\212\224-\354\273\244\353\260\213\353\213\271-\355\206\240\355\201\260\353\237\211\354\235\264\353\213\244-\342\200\224-\353\215\270\355\203\200\353\245\274-\352\270\260\353\241\235\355\225\230\354\247\200-\354\225\212\353\212\224\353\213\244-decision-19\354\235\230-delta-\354\241\260\355\225\255-\353\214\200\354\262\264.md" new file mode 100644 index 0000000..c600394 --- /dev/null +++ "b/backlog/decisions/decision-27 - Tokens-Used\353\212\224-\354\273\244\353\260\213\353\213\271-\355\206\240\355\201\260\353\237\211\354\235\264\353\213\244-\342\200\224-\353\215\270\355\203\200\353\245\274-\352\270\260\353\241\235\355\225\230\354\247\200-\354\225\212\353\212\224\353\213\244-decision-19\354\235\230-delta-\354\241\260\355\225\255-\353\214\200\354\262\264.md" @@ -0,0 +1,40 @@ +--- +id: decision-27 +title: Tokens-Used는 커밋당 토큰량이다 — 델타를 기록하지 않는다 (decision-19의 delta 조항 대체) +date: '2026-10-03 07:20' +status: accepted +--- +## Context + +`Tokens-Used`는 처음부터 "이 커밋을 만드는 데 든 토큰"을 남기려는 트레일러였다(README: +"이 커밋에 든 토큰"). 그런데 구현은 "직전 커밋 이후 세션에서 소비된 토큰 전부"(델타)를 +세어 왔고, 문서와 아티팩트도 그걸 "누적이 아니라 델타"라며 설계처럼 설명했다. decision-19는 +여기서 더 나아가 `delta=<구간 전체>`를 트레일러에 함께 남기기로 했다. + +2026-10-03 유저 지시: **델타로 측정하지 않는다. 설계 의도는 커밋당 토큰량이다.** + +델타는 그 구간의 무관한 대화·탐색·다른 작업을 전부 이 커밋에 얹는다. 커밋을 만들지 않은 +작업의 토큰이 우연히 다음 커밋에 몰리고, 커밋을 자주 하느냐에 따라 값이 달라진다 — 커밋의 +비용이 아니라 커밋 간격을 잰다. + +## Decision + +**`Tokens-Used`는 이 커밋에 귀속된 응답의 토큰 합이다.** 귀속은 응답의 `tool_use`가 이 커밋이 +바꾼 파일을 실제로 건드렸는지로 판정한다(GF-129). + +- 형식은 `Tokens-Used: in=<입력> out=<출력>`이다. 델타는 **어디에도 기록하지 않는다** — + decision-19의 `delta=<구간 전체>` 조항을 대체한다. +- 응답 하나는 한 커밋에만 귀속된다. 직전 커밋 이후 구간이 아니라 세션 전체에서 아직 귀속되지 + 않은 응답을 찾는다 — 그래야 "a 수정 → b 먼저 커밋 → a 커밋" 순서에서 a의 토큰을 놓치지 않는다. +- 서브에이전트가 한 작업의 토큰도 포함한다(트랜스크립트 `<세션>/subagents/*.jsonl`). +- 어느 파일도 건드리지 않은 응답(읽기·탐색·대화)은 어느 커밋에도 들어가지 않는다. 커밋당 + 토큰량이라는 정의에서 의도된 결과다. + +## Consequences + +- 값이 작아지고 커밋 간격에 덜 흔들린다. 대신 "세션이 쓴 토큰 총량"은 트레일러를 다 더해도 + 나오지 않는다 — 탐색 비용은 기록되지 않는다. +- 귀속 판정은 경로 문자열 대조라 완벽하지 않다. 경로를 쓰지 않고 파일을 바꾸는 명령 + (예: `sed -i` 대상이 글롭)은 놓칠 수 있다. +- 기존 커밋의 `Tokens-Used`(단일 숫자, 델타)는 고치지 않는다. 형식이 달라 구별된다. +- README, doc-3, doc-16, doc-21, 아티팩트 "훅 해부"의 델타 서술을 바꾼다. 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 e8bd9ff..7b4f60c 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" @@ -1,16 +1,16 @@ --- id: GF-129 title: 토큰·툴콜 측정을 응답 단위 중복 제거와 커밋 귀속으로 재작성 -status: To Do -assignee: [] +status: In Progress +assignee: + - '@claude' created_date: '2026-09-25 19:33' -updated_date: '2026-10-03 03:39' +updated_date: '2026-10-03 07:20' labels: - hooks - trailers - measurement -dependencies: - - GF-128 +dependencies: [] references: - decision-19 - decision-22 @@ -20,7 +20,7 @@ documentation: - backlog/docs/doc-16 - 토큰·툴콜-측정-방법과-한계.md - backlog/docs/doc-18 - 재설계-작업-순서와-의존성.md modified_files: - - hooks/prepare-commit-msg + - hooks/post-commit priority: high type: bug --- @@ -41,15 +41,18 @@ type: bug - [ ] #1 assistant 줄을 (requestId, message.id)로 그룹핑해 usage를 응답당 한 번만 계상한다 - [ ] #2 한 응답의 tool_use 블록은 여러 줄에 흩어져 있으므로 모든 줄에서 모아 귀속 판정에 쓴다 -- [ ] #3 귀속 대상 경로는 git diff --cached --name-only로 얻는다 -- [ ] #4 file_path/notebook_path 필드를 가진 도구는 저장소 상대경로로 정규화해 완전 일치로 판정한다 -- [ ] #5 Bash 명령은 명령 문자열에 저장소 상대경로가 들어 있는지로 판정하며, 파일명이나 파일명 앞토큰으로는 매칭하지 않는다 -- [ ] #6 Tokens-Used를 in=<입력> out=<출력> delta=<구간 전체> 한 줄로 기록한다 -- [ ] #7 in은 input_tokens와 cache_creation_input_tokens, cache_read_input_tokens의 합이다 -- [ ] #8 delta는 귀속 필터를 적용하지 않은 구간 전체 합이다 -- [ ] #9 Tool-Calls는 대상 경로를 실제로 건드린 tool_use 블록 개수다 -- [ ] #10 구간에 턴이 없으면 in=0 out=0 delta=0이고, 턴은 있으나 귀속된 게 없으면 no-attributed-turn 사유를 남겨 진짜 0과 구별된다 -- [ ] #11 트랜스크립트를 읽을 수 없으면 사유 슬러그와 함께 unavailable로 남긴다 +- [ ] #3 file_path/notebook_path 필드를 가진 도구는 저장소 상대경로로 정규화해 완전 일치로 판정한다 +- [ ] #4 Bash 명령은 명령 문자열에 저장소 상대경로가 들어 있는지로 판정하며, 파일명이나 파일명 앞토큰으로는 매칭하지 않는다 +- [ ] #5 in은 input_tokens와 cache_creation_input_tokens, cache_read_input_tokens의 합이다 +- [ ] #6 Tool-Calls는 대상 경로를 실제로 건드린 tool_use 블록 개수다 +- [ ] #7 트랜스크립트를 읽을 수 없으면 사유 슬러그와 함께 unavailable로 남긴다 +- [ ] #8 귀속 대상 경로는 방금 만든 커밋이 바꾼 파일 목록이다 (post-commit 시점이라 git diff --cached는 비어 있으므로 git diff-tree --root --no-commit-id --name-only -r HEAD) +- [ ] #9 Tokens-Used를 in=<입력> out=<출력> 한 줄로 기록한다. 직전 커밋 이후 구간 전체(델타)는 어디에도 기록하지 않는다 +- [ ] #10 세션 트랜스크립트 전체를 훑되, 응답 하나는 한 커밋에만 귀속된다 — 이미 귀속된 응답 키를 git-dir의 기록 파일에 남겨 다시 세지 않는다(세션이 바뀌면 기록을 새로 시작) +- [ ] #11 서브에이전트 트랜스크립트(<세션>/subagents/*.jsonl)도 귀속 대상에 포함한다 +- [ ] #12 귀속된 응답이 하나도 없으면 in=0 out=0에 no-attributed-turn 사유를 붙여 진짜 측정값과 구별한다 +- [ ] #13 decision-27로 'Tokens-Used는 커밋당 토큰량, 델타 기록 안 함'을 기록하고 decision-19의 delta 조항을 대체한다고 명시한다 +- [ ] #14 README, doc-3, doc-16, doc-21과 아티팩트 '훅 해부'의 Tokens-Used 서술을 커밋당 토큰량으로 바꾼다 ## Definition of Done @@ -59,6 +62,17 @@ type: bug - [ ] #3 이 저장소 자신의 커밋이 새 훅으로 정상 생성되는지 확인 +## Implementation Plan + + +1. decision-27 기록 +2. 테스트 먼저(TDD): 귀속 일치/불일치, 경로 정규화(절대→상대, 심볼릭 링크), Bash 경로 포함 판정(파일명만으로는 불일치), 응답 1회 귀속(다음 커밋에서 재계상 안 함), a→b→a 순서, 서브에이전트 트랜스크립트, no-attributed-turn, 세션 변경 시 기록 초기화, 형식 in=/out= +3. post-commit 구현 (서브에이전트 위임) +4. README·doc-3·doc-16·doc-21 갱신 +5. 실제 커밋으로 독립 합산 대조 +6. 아티팩트 '훅 해부' 갱신·재발행 + + ## Comments @@ -67,4 +81,10 @@ 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 구현' 절. --- + +author: @claude +created: 2026-10-03 07:20 +--- +2026-10-03 유저 지시로 범위 변경: (1) 델타를 측정·기록하지 않는다 — 설계 의도는 커밋당 토큰량이다. 예전 AC#6(in/out/delta 형식)·#8(delta=구간 전체)·#10(구간 0/no-attributed-turn)을 지우고 새 AC로 대체. (2) GF-128 의존성을 풀고 지금 post-commit에 구현한다(GF-139와 같은 방식). prepare-commit-msg로 옮기는 건 GF-128 때. (3) post-commit 시점이라 귀속 경로는 git diff --cached가 아니라 커밋의 파일 목록. (4) 'a 수정 → b 먼저 커밋 → a 커밋' 순서에서 놓치지 않도록 구간이 아니라 세션 전체를 훑고 응답 단위로 한 번만 귀속. (5) 서브에이전트 트랜스크립트 포함. +--- From 684ab8fe61b44704f5899c453d6712f68ee3e24b Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:23:34 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[test]=20=EC=84=9C=EB=B8=8C=EC=97=90?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=ED=8A=B8=20=ED=8A=B8=EB=9E=9C=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=ED=97=AC=ED=8D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: 7472454 Tool-Calls: 16 Hooks-Commit: a865265 Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- tests/isolated_repo.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/isolated_repo.py b/tests/isolated_repo.py index 47aa27d..95a8ae7 100644 --- a/tests/isolated_repo.py +++ b/tests/isolated_repo.py @@ -357,6 +357,31 @@ def write_transcript(self, home, records, *, session_id="fake-session", cwd=None handle.write(line + "\n") return path + def write_subagent_transcript( + self, home, records, *, agent="agent-1", session_id="fake-session", cwd=None + ): + """서브에이전트 트랜스크립트(<세션 디렉터리>/<세션 ID>/subagents/.jsonl). + + Claude Code는 서브에이전트의 대화를 부모 세션 트랜스크립트 옆 이 경로에 같은 + JSONL 형식으로 남긴다(GF-129). 형식 규칙은 write_transcript와 같다. + """ + repo_path = Path(cwd) if cwd is not None else self.repo + directory = ( + Path(home) + / ".claude" + / "projects" + / transcript_slug(repo_path) + / session_id + / "subagents" + ) + directory.mkdir(parents=True, exist_ok=True) + path = directory / f"{agent}.jsonl" + lines = [r if isinstance(r, str) else json.dumps(r) for r in records] + with path.open("a", encoding="utf-8") as handle: + for line in lines: + handle.write(line + "\n") + return path + def claude_env(self, home, *, session_id="fake-session", version="2-1-0"): return { "HOME": str(home), From b81b517ec2acba358a43ff31d29de749679657cc Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:25:15 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[test][post-commit]=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EB=8B=B9=20=ED=86=A0=ED=81=B0=20=EA=B7=80=EC=86=8D=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: 867374 Tool-Calls: 0 Hooks-Commit: 66d5053 Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- tests/test_token_usage_measurement.py | 592 ++++++++++++++++---------- tests/test_trailer_ai_attribution.py | 11 +- 2 files changed, 383 insertions(+), 220 deletions(-) diff --git a/tests/test_token_usage_measurement.py b/tests/test_token_usage_measurement.py index 6e71cff..3ae1f8f 100644 --- a/tests/test_token_usage_measurement.py +++ b/tests/test_token_usage_measurement.py @@ -1,299 +1,455 @@ -"""Tokens-Used/Tool-Calls 측정을 본다(decision-5 확장, GF-96/GF-97/GF-112). +"""Tokens-Used/Tool-Calls 측정을 본다(decision-5 확장, GF-96/GF-97/GF-112/GF-139/GF-129). -구 robustness-post-commit.bats의 토큰 측정 케이스. 값은 Claude Code 세션 -트랜스크립트(JSONL)의 델타 구간을 집계해 얻고, 커서는 .git/.gitformat-token-cursor에 -남는다. 측정할 수 없는 경우에도 조용히 생략하지 않고 unavailable (사유)로 명시하는 -것이 GF-97이 고정한 동작이다. +구 robustness-post-commit.bats의 토큰 측정 케이스에서 출발했다. 값은 Claude Code 세션 +트랜스크립트(JSONL)와 서브에이전트 트랜스크립트에서 얻는다. 측정할 수 없는 경우에도 +조용히 생략하지 않고 unavailable (사유)로 명시하는 것이 GF-97이 고정한 동작이다. + +GF-129(decision-27)에서 의미가 바뀌었다. 예전에는 .git/.gitformat-token-cursor에 "이미 +읽은 줄 수"를 두고 직전 커밋 이후 구간 전체(델타)를 더했다. 지금은 세션 전체에서 이 +커밋이 바꾼 파일을 건드린 응답만 골라(귀속) 그 usage를 `in=<입력> out=<출력>`으로 남기고, +귀속한 응답 키를 .git/.gitformat-token-attributed에 적어 한 응답이 두 커밋에 들어가지 +않게 한다. 델타·커서를 전제로 하던 GF-96/GF-139 테스트는 같은 관심사(중복 계상 방지, +세션 변경)를 새 의미로 다시 쓴 것이다. GF-111에서 트랜스크립트 파싱이 jq 서브프로세스에서 json.loads()로 바뀌어 jq 의존성이 사라졌다 — "jq가 없으면 skip"하던 가드가 없으므로 이 케이스들은 모든 환경에서 실제로 실행된다. """ +import os import unittest from isolated_repo import IsolatedRepoTestCase -CURSOR_NAME = ".gitformat-token-cursor" +RECORD_NAME = ".gitformat-token-attributed" +OLD_CURSOR_NAME = ".gitformat-token-cursor" +NO_ATTRIBUTED_TOKENS = "Tokens-Used: in=0 out=0 (no-attributed-turn)" +NO_ATTRIBUTED_CALLS = "Tool-Calls: 0 (no-attributed-turn)" -def assistant(input_tokens, output_tokens, tool_names=()): - return { - "type": "assistant", - "message": { - "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, - "content": [{"type": "tool_use", "name": n} for n in tool_names], - }, - } +def tool_use(name, **tool_input): + return {"type": "tool_use", "name": name, "input": tool_input} + +def edit(path): + return tool_use("Edit", file_path=str(path)) -def response_line(request_id, message_id, input_tokens, output_tokens, block): - """API 응답 하나가 content 블록마다 한 줄씩 기록되는 실제 트랜스크립트 모양. + +def bash(command): + return tool_use("Bash", command=command) + + +TEXT_BLOCK = {"type": "text", "text": "hi"} + + +def response( + request_id, + message_id, + blocks, + input_tokens=0, + output_tokens=0, + cache_creation=0, + cache_read=0, +): + """API 응답 하나를 실제 트랜스크립트 모양(content 블록마다 한 줄)으로 만든다. 같은 응답의 줄들은 최상위 requestId와 message.id를 공유하고 usage도 똑같이 - 반복하며, content만 블록별로 다르다(GF-139 실측). + 반복하며, content만 블록별로 다르다(GF-139 실측). 줄 목록을 돌려준다. """ - return { - "type": "assistant", - "requestId": request_id, - "message": { - "id": message_id, - "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, - "content": [block], - }, + usage = { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_creation_input_tokens": cache_creation, + "cache_read_input_tokens": cache_read, } + lines = [] + for block in blocks: + line = { + "type": "assistant", + "message": {"usage": dict(usage), "content": [block]}, + } + if request_id is not None: + line["requestId"] = request_id + if message_id is not None: + line["message"]["id"] = message_id + lines.append(line) + return lines -TEXT_BLOCK = {"type": "text", "text": "hi"} -TOOL_BLOCK = {"type": "tool_use", "name": "bash"} +class TokenUsageMeasurementTest(IsolatedRepoTestCase): + def record_lines(self): + return self.git_dir_file(RECORD_NAME).read_text(encoding="utf-8").splitlines() + def stage(self, *relpaths): + for relpath in relpaths: + self.write(relpath, f"{relpath}\n") + self.git_ok("add", *relpaths) -class TokenUsageMeasurementTest(IsolatedRepoTestCase): - def cursor_value(self): - return self.git_dir_file(CURSOR_NAME).read_text(encoding="utf-8").strip() + def commit_ai(self, message, home, **kwargs): + self.assertAccepted(self.commit(message, env=self.claude_env(home, **kwargs))) + return self.head_message() + + # ── 귀속 판정 ──────────────────────────────────────────────── - def test_알려진_usage_값이_정확히_합산된다(self): - """[GF-96] 알려진 usage 값을 가진 트랜스크립트로 커밋하면 Tokens-Used/Tool-Calls가 정확히 합산된다""" + def test_커밋한_파일을_건드린_응답만_합산한다(self): + """[GF-129] 커밋한 파일을 건드린 응답의 usage만 더하고, 다른 파일만 건드린 응답은 뺀다""" home = self.fake_home() self.write_transcript( home, - [ - { - "type": "assistant", - "message": { - "usage": {"input_tokens": 10, "output_tokens": 5}, - "content": [{"type": "text", "text": "hi"}], - }, - }, - assistant(20, 10, ("bash", "grep")), - {"type": "user", "message": {"content": "hello"}}, - ], + response("req-1", "msg-1", [edit(self.repo / "a.txt")], 10, 5) + + response( + "req-2", "msg-2", [tool_use("Read", file_path="other.txt")], 1000, 500 + ) + + response("req-3", "msg-3", [TEXT_BLOCK], 2000, 900) + + [{"type": "user", "message": {"content": "hello"}}], ) - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - self.assertAccepted( - self.commit("[feat] token usage first commit", env=self.claude_env(home)) + self.stage("a.txt") + message = self.commit_ai("[feat] attribute edited file", home) + self.assertTrailerCount(message, "Tokens-Used: in=10 out=5", 1) + self.assertTrailerCount(message, "Tool-Calls: 1", 1) + self.assertEqual(["fake-session", "req-1 msg-1"], self.record_lines()) + + def test_in은_입력과_캐시_토큰의_합이고_out은_출력이다(self): + """[GF-129] in=input+cache_creation+cache_read, out=output_tokens 형식으로 남긴다""" + home = self.fake_home() + self.write_transcript( + home, + response( + "req-1", + "msg-1", + [edit("a.txt")], + input_tokens=3, + output_tokens=7, + cache_creation=100, + cache_read=2000, + ), ) - message = self.head_message() - self.assertTrailerCount(message, "Tokens-Used: 45", 1) - self.assertTrailerCount(message, "Tool-Calls: 2", 1) - self.assertEqual("fake-session 3", self.cursor_value()) + self.stage("a.txt") + message = self.commit_ai("[feat] in out format", home) + self.assertTrailerCount(message, "Tokens-Used: in=2103 out=7", 1) + self.assertNotIn("delta", message) - def test_두_번째_커밋은_새로_추가된_줄만_집계한다(self): - """[GF-96] 같은 세션의 두 번째 커밋은 첫 커밋 이후 새로 추가된 줄만 델타로 집계한다""" + def test_같은_응답의_여러_줄은_usage를_한_번만_더하고_tool_use는_모두_모은다(self): + """[GF-139/GF-129] 같은 (requestId, message.id) 줄들은 usage를 한 번만 더하고, 귀속 판정과 Tool-Calls는 모든 줄의 tool_use를 본다""" home = self.fake_home() - env = self.claude_env(home) - self.write_transcript(home, [assistant(10, 5)]) - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - self.assertAccepted(self.commit("[feat] token usage commit one", env=env)) - self.assertTrailerCount(self.head_message(), "Tokens-Used: 15", 1) + # 귀속 근거(Edit a.txt)가 첫 줄이 아니라 셋째 줄에 있다 — 첫 줄만 보면 놓친다. + self.write_transcript( + home, + response( + "req-1", + "msg-1", + [TEXT_BLOCK, bash("ls"), edit("a.txt"), bash("cat a.txt")], + 100, + 10, + ), + ) + self.stage("a.txt") + message = self.commit_ai("[feat] dedup response lines", home) + self.assertTrailerCount(message, "Tokens-Used: in=100 out=10", 1) + # 대상 경로를 건드린 블록(Edit a.txt, Bash cat a.txt)만 센다 - ls는 빠진다. + self.assertTrailerCount(message, "Tool-Calls: 2", 1) - self.write_transcript(home, [assistant(1, 1), assistant(2, 2, ("bash",))]) - self.write("b.txt", "bye\n") - self.git_ok("add", "b.txt") - self.assertAccepted(self.commit("[feat] token usage commit two", env=env)) - message = self.head_message() - self.assertTrailerCount(message, "Tokens-Used: 6", 1) + def test_실측_0은_no_attributed_turn과_구별된다(self): + """[GF-97/GF-129] 귀속된 응답의 usage가 정말 0이면 사유 없이 in=0 out=0으로 남긴다""" + home = self.fake_home() + self.write_transcript(home, response("req-1", "msg-1", [edit("a.txt")], 0, 0)) + self.stage("a.txt") + message = self.commit_ai("[feat] genuine zero tokens", home) + self.assertTrailerCount(message, "Tokens-Used: in=0 out=0", 1) self.assertTrailerCount(message, "Tool-Calls: 1", 1) - # 누적 합(21)이 아니라 델타(6)여야 한다. - self.assertTrailerCount(message, "Tokens-Used: 21", 0) - self.assertEqual("fake-session 3", self.cursor_value()) + self.assertNotIn("no-attributed-turn", message) + self.assertNotIn("unavailable", message) - def test_트랜스크립트_파일이_없으면_사유가_명시된다(self): - """[GF-97] 트랜스크립트 파일이 없으면 Tokens-Used/Tool-Calls가 unavailable (transcript-not-found)로 명시된다""" + def test_귀속된_응답이_없으면_no_attributed_turn을_남긴다(self): + """[GF-129] 어떤 응답도 커밋 파일을 건드리지 않았으면 in=0 out=0 (no-attributed-turn)이다""" home = self.fake_home() - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - self.assertAccepted( - self.commit( - "[feat] no transcript file", - env=self.claude_env(home, session_id="nonexistent-session"), - ) - ) - message = self.head_message() - self.assertTrailerCount( - message, "Tokens-Used: unavailable (transcript-not-found)", 1 - ) - self.assertTrailerCount( - message, "Tool-Calls: unavailable (transcript-not-found)", 1 + self.write_transcript( + home, + response("req-1", "msg-1", [tool_use("Read", file_path="other.txt")], 50, 5) + + response("req-2", "msg-2", [TEXT_BLOCK], 60, 6), ) - self.assertFalse(self.git_dir_file(CURSOR_NAME).exists()) + self.stage("a.txt") + message = self.commit_ai("[feat] nothing attributed", home) + self.assertTrailerCount(message, NO_ATTRIBUTED_TOKENS, 1) + self.assertTrailerCount(message, NO_ATTRIBUTED_CALLS, 1) + self.assertEqual(["fake-session"], self.record_lines()) - def test_claude_code_외_도구는_no_usage_channel로_명시된다(self): - """[GF-97] claude-code 외 AI 도구가 감지되면 unavailable (no-usage-channel)로 명시된다""" - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - # commit-msg 게이트(decision-5)는 claude-code 외 AI 도구가 감지되면 - # gitformat.aiModel이 설정돼 있을 것을 요구한다 — 이 테스트의 관심사는 그 - # 게이트 통과 이후 post-commit의 분기이므로 먼저 채워둔다. - self.git_ok("config", "gitformat.aiModel", "gpt-5") - self.assertAccepted( - self.commit("[feat] other ai tool", env={"AI_AGENT": "other-tool_1-0"}) + # ── 경로 정규화 ────────────────────────────────────────────── + + def test_절대경로와_심볼릭_링크_경로를_저장소_상대경로로_정규화한다(self): + """[GF-129] file_path가 절대경로이거나 심볼릭 링크(/tmp→/private/tmp 같은)를 거쳐도 저장소 상대경로로 정규화해 일치시킨다""" + home = self.fake_home() + link_parent = self.temp_dir(prefix="gitformat-link-") + link = link_parent / "repo-link" + os.symlink(self.repo, link) + self.write_transcript( + home, + response("req-1", "msg-1", [edit(self.repo / "sub" / "a.txt")], 1, 1) + + response("req-2", "msg-2", [edit(link / "sub" / "b.txt")], 10, 10) + + response( + "req-3", + "msg-3", + [tool_use("NotebookEdit", notebook_path="sub/c.ipynb")], + 100, + 100, + ) + # 이름만 같은 다른 위치의 파일은 귀속되지 않는다. + + response( + "req-4", "msg-4", [edit(link_parent / "sub" / "a.txt")], 1000, 1000 + ), ) - message = self.head_message() - self.assertTrailerCount(message, "AI-Tool: other-tool", 1) - self.assertTrailerCount( - message, "Tokens-Used: unavailable (no-usage-channel)", 1 + self.stage("sub/a.txt", "sub/b.txt", "sub/c.ipynb") + message = self.commit_ai("[feat] normalize paths", home) + self.assertTrailerCount(message, "Tokens-Used: in=111 out=111", 1) + self.assertTrailerCount(message, "Tool-Calls: 3", 1) + + def test_Bash는_저장소_상대경로가_든_명령만_귀속한다(self): + """[GF-129] Bash 명령은 저장소 상대경로(또는 절대경로)를 담을 때만 귀속하고, 하위 경로 파일의 파일명만으로는 매칭하지 않는다""" + home = self.fake_home() + self.write_transcript( + home, + response("req-1", "msg-1", [bash("ruff check hooks/post-commit")], 1, 1) + + response( + "req-2", "msg-2", [bash(f"cat {self.repo}/hooks/post-commit")], 10, 10 + ) + # 파일명만: cd 후 상대 이름으로 접근해도 귀속하지 않는다(AC#4). + + response( + "req-3", "msg-3", [bash("cd hooks && cat post-commit")], 100, 100 + ) + # 앞뒤로 다른 글자가 붙은 경로는 다른 파일이다. + + response( + "req-4", + "msg-4", + [bash("cat myhooks/post-commit hooks/post-commit-old")], + 1000, + 1000, + ), ) - self.assertTrailerCount(message, "Tool-Calls: unavailable (no-usage-channel)", 1) + self.stage("hooks/post-commit") + message = self.commit_ai("[feat] bash path matching", home) + self.assertTrailerCount(message, "Tokens-Used: in=11 out=11", 1) + self.assertTrailerCount(message, "Tool-Calls: 2", 1) - def test_실측_0은_unavailable이_아니라_0으로_명시된다(self): - """[GF-97] 실측 결과가 정말 0이면 unavailable이 아니라 Tokens-Used: 0/Tool-Calls: 0으로 명시된다""" + def test_저장소_최상위_파일은_경계로_구분된_파일명으로_매칭한다(self): + """[GF-129] 최상위 파일은 경로가 곧 파일명이라 경계로 구분된 이름을 받되, 하위 디렉터리의 같은 이름이나 더 긴 이름은 받지 않는다""" home = self.fake_home() - self.write_transcript(home, [assistant(0, 0)]) - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - self.assertAccepted( - self.commit("[feat] genuine zero token delta", env=self.claude_env(home)) + self.write_transcript( + home, + response("req-1", "msg-1", [bash("echo hi > a.txt")], 1, 1) + + response( + "req-2", "msg-2", [bash("sed -i '' 's/x/y/' \"./a.txt\"")], 10, 10 + ) + + response("req-3", "msg-3", [bash("cat sub/a.txt")], 100, 100) + + response("req-4", "msg-4", [bash("cat a.txt.bak ba.txt")], 1000, 1000), ) - message = self.head_message() - self.assertTrailerCount(message, "Tokens-Used: 0", 1) - self.assertTrailerCount(message, "Tool-Calls: 0", 1) - self.assertNotIn("unavailable", message) - self.assertEqual("fake-session 1", self.cursor_value()) + self.stage("a.txt") + message = self.commit_ai("[feat] root file matching", home) + self.assertTrailerCount(message, "Tokens-Used: in=11 out=11", 1) + self.assertTrailerCount(message, "Tool-Calls: 2", 1) + + # ── 한 응답은 한 커밋에만 ───────────────────────────────────── - def test_깨진_줄이_있으면_배치_전체가_실패하고_커서는_그대로다(self): - """[GF-112] 새 구간에 깨진 줄이 하나라도 있으면 배치 전체가 unavailable (transcript-parse-failed)이고 커서는 그대로다""" + def test_한_응답은_두_커밋에_중복_계상되지_않는다(self): + """[GF-129] 두 파일을 건드린 응답은 먼저 커밋된 쪽에만 귀속되고 다음 커밋에서 다시 세지 않는다""" home = self.fake_home() - env = self.claude_env(home) - self.write_transcript(home, [assistant(10, 5)]) - self.write("a.txt", "hi\n") - self.git_ok("add", "a.txt") - self.assertAccepted( - self.commit("[feat] parse failure baseline commit", env=env) + self.write_transcript( + home, response("req-1", "msg-1", [edit("a.txt"), edit("b.txt")], 100, 10) ) - self.assertTrailerCount(self.head_message(), "Tokens-Used: 15", 1) - self.assertEqual("fake-session 1", self.cursor_value()) + self.stage("a.txt") + message = self.commit_ai("[feat] first of two files", home) + self.assertTrailerCount(message, "Tokens-Used: in=100 out=10", 1) + + self.stage("b.txt") + message = self.commit_ai("[feat] second of two files", home) + self.assertTrailerCount(message, NO_ATTRIBUTED_TOKENS, 1) + self.assertTrailerCount(message, NO_ATTRIBUTED_CALLS, 1) - # 새 구간에 정상 줄과 깨진 줄을 함께 넣는다 — 깨진 줄만 건너뛰고 정상 줄을 - # 집계하면 "일부만 집계된 값"이 정상값인 척 기록되므로, 배치 전체가 실패해야 - # 한다(GF-111 AC #4가 고정한 동작). + def test_커밋_뒤에_이어_기록된_같은_응답의_줄도_다시_세지_않는다(self): + """[GF-139/GF-129] 응답 하나의 줄이 커밋 앞뒤로 나뉘어 기록돼도 응답 키로 기억해 다음 커밋에 다시 넣지 않는다""" + home = self.fake_home() self.write_transcript( - home, [assistant(7, 3), "{ this line is not valid json"] + home, response("req-1", "msg-1", [edit("a.txt")], 100, 10) ) - self.write("b.txt", "bye\n") - self.git_ok("add", "b.txt") - self.assertAccepted(self.commit("[feat] parse failure commit", env=env)) - message = self.head_message() - self.assertTrailerCount( - message, "Tokens-Used: unavailable (transcript-parse-failed)", 1 + self.stage("a.txt") + self.commit_ai("[feat] straddle commit one", home) + + self.write_transcript( + home, + response("req-1", "msg-1", [edit("b.txt")], 100, 10) + + response("req-2", "msg-2", [edit("b.txt")], 5, 1), ) - self.assertTrailerCount( - message, "Tool-Calls: unavailable (transcript-parse-failed)", 1 + self.stage("b.txt") + message = self.commit_ai("[feat] straddle commit two", home) + self.assertTrailerCount(message, "Tokens-Used: in=5 out=1", 1) + self.assertTrailerCount(message, "Tool-Calls: 1", 1) + self.assertEqual( + ["fake-session", "req-1 msg-1", "req-2 msg-2"], self.record_lines() ) - self.assertTrailerCount(message, "Tokens-Used: 10", 0) - # 커서가 그대로라야 다음 커밋이 같은 구간을 다시 시도한다. - self.assertEqual("fake-session 1", self.cursor_value()) - def test_다른_세션의_커서가_있어도_새_세션_첫_커밋을_집계한다(self): - """[GF-139] 다른 세션이 남긴 커서가 있어도 새 세션의 첫 커밋은 트랜스크립트 0줄부터 집계한다""" + def test_a를_고치고_b를_먼저_커밋해도_a의_응답은_뒤_커밋에_귀속된다(self): + """[GF-129] a 수정 → b 커밋 → a 커밋 순서에서도 a를 고친 응답이 a 커밋에 들어간다(구간이 아니라 세션 전체를 본다)""" 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"), - ) + home, + response("req-a", "msg-a", [edit("a.txt")], 300, 30) + + response("req-b", "msg-b", [edit("b.txt")], 20, 2), ) - self.assertEqual("old 3", self.cursor_value()) + self.stage("b.txt") + message = self.commit_ai("[feat] commit b first", home) + self.assertTrailerCount(message, "Tokens-Used: in=20 out=2", 1) - # 새 세션의 트랜스크립트는 커서(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.stage("a.txt") + message = self.commit_ai("[feat] commit a later", home) + self.assertTrailerCount(message, "Tokens-Used: in=300 out=30", 1) self.assertTrailerCount(message, "Tool-Calls: 1", 1) - self.assertEqual("new 1", self.cursor_value()) - def test_예전_숫자_커서는_세션_불일치로_취급한다(self): - """[GF-139] 숫자만 담긴 예전 형식 커서는 세션 불일치로 취급해 0줄부터 집계한다""" + def test_서브에이전트_트랜스크립트의_응답도_귀속한다(self): + """[GF-129] <세션>/subagents/*.jsonl의 응답도 같은 규칙으로 귀속해 더한다""" 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.write_transcript(home, response("req-1", "msg-1", [edit("a.txt")], 10, 1)) + self.write_subagent_transcript( + home, + response("req-s1", "msg-s1", [edit("a.txt"), bash("cat a.txt")], 200, 20) + + response("req-s2", "msg-s2", [edit("other.txt")], 9000, 900), + ) + self.stage("a.txt") + message = self.commit_ai("[feat] subagent attribution", home) + self.assertTrailerCount(message, "Tokens-Used: in=210 out=21", 1) + self.assertTrailerCount(message, "Tool-Calls: 3", 1) + + def test_세션이_바뀌면_귀속_기록을_새로_시작한다(self): + """[GF-139/GF-129] 기록 파일의 세션 ID가 지금 세션과 다르면 이전 기록을 버리고 새로 시작한다""" + home = self.fake_home() + self.write_transcript( + home, response("req-1", "msg-1", [edit("a.txt")], 10, 1), session_id="old" + ) + self.stage("a.txt") + self.commit_ai("[feat] old session commit", home, session_id="old") + self.assertEqual(["old", "req-1 msg-1"], self.record_lines()) + + # 같은 응답 키가 새 세션에 다시 나와도(키는 세션 사이에 보장된 유일값이 아니다) + # 이전 세션의 기록 때문에 빠지면 안 된다. + self.write_transcript( + home, response("req-1", "msg-1", [edit("b.txt")], 7, 3), session_id="new" ) - self.assertTrailerCount(self.head_message(), "Tokens-Used: 18", 1) - self.assertEqual("fake-session 2", self.cursor_value()) + self.stage("b.txt") + message = self.commit_ai("[feat] new session commit", home, session_id="new") + self.assertTrailerCount(message, "Tokens-Used: in=7 out=3", 1) + self.assertEqual(["new", "req-1 msg-1"], self.record_lines()) - def test_같은_응답의_여러_줄은_usage를_한_번만_더한다(self): - """[GF-139] (requestId, message.id)가 같은 assistant 줄은 usage를 한 번만 더하고 tool_use는 줄마다 센다""" + def test_id가_없는_줄은_각자_하나의_응답이고_파일명과_줄_번호로_기억한다(self): + """[GF-139/GF-129] requestId나 message.id가 없는 줄은 줄마다 별개 응답으로 세고, '<파일>:<줄 번호>' 키로 기록해 다시 세지 않는다""" home = self.fake_home() self.write_transcript( home, + response(None, "msg-1", [edit("a.txt")], 7, 3) + + response("req-1", None, [edit("a.txt")], 7, 3) + + response(None, None, [edit("a.txt")], 7, 3), + ) + self.stage("a.txt") + message = self.commit_ai("[feat] lines without ids", home) + self.assertTrailerCount(message, "Tokens-Used: in=21 out=9", 1) + self.assertEqual( [ - 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), + "fake-session", + "fake-session.jsonl:1", + "fake-session.jsonl:2", + "fake-session.jsonl:3", ], + self.record_lines(), ) - self.write("a.txt", "hi\n") + + self.write("a.txt", "changed\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) + message = self.commit_ai("[feat] lines without ids again", home) + self.assertTrailerCount(message, NO_ATTRIBUTED_TOKENS, 1) + + # ── 실패 경로 ──────────────────────────────────────────────── - def test_id가_없는_줄은_줄_단위로_더한다(self): - """[GF-139] requestId나 message.id가 없는 줄은 같은 usage라도 줄마다 더한다""" + def test_깨진_줄이_있으면_배치_전체가_실패하고_기록은_그대로다(self): + """[GF-112/GF-129] 트랜스크립트에 깨진 줄이 하나라도 있으면 unavailable (transcript-parse-failed)이고 귀속 기록은 바뀌지 않는다""" 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, response("req-1", "msg-1", [edit("a.txt")], 10, 5)) + self.stage("a.txt") + self.commit_ai("[feat] parse failure baseline commit", home) + before = self.record_lines() + + # 정상 응답과 깨진 줄을 함께 넣는다 — 깨진 줄만 건너뛰고 나머지를 집계하면 + # "일부만 집계된 값"이 정상값인 척 기록되므로 배치 전체가 실패해야 한다 + # (GF-111 AC #4가 고정한 동작). self.write_transcript( home, - [no_request_id, dict(no_request_id), no_message_id, assistant(7, 3)], + response("req-2", "msg-2", [edit("b.txt")], 7, 3) + + ["{ this line is not valid json"], ) - 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.stage("b.txt") + message = self.commit_ai("[feat] parse failure commit", home) + self.assertTrailerCount( + message, "Tokens-Used: unavailable (transcript-parse-failed)", 1 + ) + self.assertTrailerCount( + message, "Tool-Calls: unavailable (transcript-parse-failed)", 1 ) - self.assertTrailerCount(self.head_message(), "Tokens-Used: 40", 1) + self.assertEqual(before, self.record_lines()) - def test_커서_경계를_걸친_응답은_두_커밋에_중복_계상되지_않는다(self): - """[GF-139] 한 응답의 줄이 커밋 경계 앞뒤로 나뉘면 뒤 커밋은 그 usage를 다시 더하지 않는다""" + def test_서브에이전트_트랜스크립트가_깨져도_배치_전체가_실패한다(self): + """[GF-129] 서브에이전트 트랜스크립트의 깨진 줄도 transcript-parse-failed로 배치 전체를 실패시킨다""" 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("req-1", "msg-1", [edit("a.txt")], 10, 5)) + self.write_subagent_transcript(home, ["{ broken"]) + self.stage("a.txt") + message = self.commit_ai("[feat] broken subagent transcript", home) + self.assertTrailerCount( + message, "Tokens-Used: unavailable (transcript-parse-failed)", 1 + ) + self.assertFalse(self.git_dir_file(RECORD_NAME).exists()) - self.write_transcript( - home, - [ - response_line("req-1", "msg-1", 100, 10, TOOL_BLOCK), - response_line("req-2", "msg-2", 5, 1, TEXT_BLOCK), - ], + def test_트랜스크립트_파일이_없으면_사유가_명시된다(self): + """[GF-97] 트랜스크립트 파일이 없으면 Tokens-Used/Tool-Calls가 unavailable (transcript-not-found)로 명시된다""" + home = self.fake_home() + self.stage("a.txt") + message = self.commit_ai( + "[feat] no transcript file", home, session_id="nonexistent-session" + ) + self.assertTrailerCount( + message, "Tokens-Used: unavailable (transcript-not-found)", 1 + ) + self.assertTrailerCount( + message, "Tool-Calls: unavailable (transcript-not-found)", 1 + ) + self.assertFalse(self.git_dir_file(RECORD_NAME).exists()) + + def test_claude_code_외_도구는_no_usage_channel로_명시된다(self): + """[GF-97] claude-code 외 AI 도구가 감지되면 unavailable (no-usage-channel)로 명시된다""" + self.stage("a.txt") + # commit-msg 게이트(decision-5)는 claude-code 외 AI 도구가 감지되면 + # gitformat.aiModel이 설정돼 있을 것을 요구한다 — 이 테스트의 관심사는 그 + # 게이트 통과 이후 post-commit의 분기이므로 먼저 채워둔다. + self.git_ok("config", "gitformat.aiModel", "gpt-5") + self.assertAccepted( + self.commit("[feat] other ai tool", env={"AI_AGENT": "other-tool_1-0"}) ) - 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()) + self.assertTrailerCount(message, "AI-Tool: other-tool", 1) + self.assertTrailerCount( + message, "Tokens-Used: unavailable (no-usage-channel)", 1 + ) + self.assertTrailerCount( + message, "Tool-Calls: unavailable (no-usage-channel)", 1 + ) + + def test_예전_델타_커서_파일은_쓰지_않고_지운다(self): + """[GF-129] 예전 .gitformat-token-cursor는 값에 영향을 주지 않고, 측정에 성공하면 지워진다""" + home = self.fake_home() + self.write_transcript(home, response("req-1", "msg-1", [edit("a.txt")], 10, 5)) + # 예전 커서가 "이미 1줄 읽었음"이라고 해도 그 응답은 아직 귀속된 적이 없다. + self.git_dir_file(OLD_CURSOR_NAME).write_text( + "fake-session 1", encoding="utf-8" + ) + self.stage("a.txt") + message = self.commit_ai("[feat] legacy cursor ignored", home) + self.assertTrailerCount(message, "Tokens-Used: in=10 out=5", 1) + self.assertFalse(self.git_dir_file(OLD_CURSOR_NAME).exists()) if __name__ == "__main__": diff --git a/tests/test_trailer_ai_attribution.py b/tests/test_trailer_ai_attribution.py index 637a1ad..3d6bd47 100644 --- a/tests/test_trailer_ai_attribution.py +++ b/tests/test_trailer_ai_attribution.py @@ -68,7 +68,14 @@ def test_밑줄과_점이_든_경로에서도_트랜스크립트를_찾는다(se "message": { "model": "claude-slug-fix-test", "usage": {"input_tokens": 10, "output_tokens": 5}, - "content": [{"type": "tool_use", "name": "bash"}], + # 커밋할 a.txt를 건드린 응답이라야 Tokens-Used에 귀속된다(GF-129). + "content": [ + { + "type": "tool_use", + "name": "Edit", + "input": {"file_path": "a.txt"}, + } + ], }, } ], @@ -86,7 +93,7 @@ def test_밑줄과_점이_든_경로에서도_트랜스크립트를_찾는다(se ) message = self.head_message(cwd=nested) self.assertTrailerCount(message, "AI-Model: claude-slug-fix-test", 1) - self.assertTrailerCount(message, "Tokens-Used: 15", 1) + self.assertTrailerCount(message, "Tokens-Used: in=10 out=5", 1) self.assertTrailerCount(message, "Tool-Calls: 1", 1) From 526ea53b774abb86815fd56968fa6351a4b666ea Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:28:44 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[feat][post-commit]=20Tokens-Used=EB=A5=BC?= =?UTF-8?q?=20=EC=BB=A4=EB=B0=8B=EB=8B=B9=20=EA=B7=80=EC=86=8D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=3523137 out=12193 Tool-Calls: 31 Hooks-Commit: 661b90f Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- hooks/post-commit | 426 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 313 insertions(+), 113 deletions(-) diff --git a/hooks/post-commit b/hooks/post-commit index 6773514..d4929f7 100755 --- a/hooks/post-commit +++ b/hooks/post-commit @@ -84,15 +84,19 @@ def local_get(key): # Task-Id 접두어 기본값, AI 도구 식별자, Co-Authored-By 값)이 모여 있다. 값만 # 여기서 읽고, 이 값들을 쓰는 로직은 이 파일 안에서만 독립적으로 유지한다. MARKER = os.path.join(GIT_DIR, conf_get("gitformat.markerFile")) -# Tokens-Used/Tool-Calls 델타 커서(decision-5 확장). .gitformat-verified와 같은 -# "커밋 간 git-미추적 상태 파일" 관례를 따르되, 이 파일은 삭제되지 않고 -# measure_claude_code_token_usage()가 매번 "<세션 ID> <최신 트랜스크립트 줄 수>" -# 한 줄로 덮어쓴다. 트랜스크립트는 세션마다 새 파일이라 줄 수만 저장하면 새 세션의 -# 짧은 파일을 이전 세션의 줄 수로 읽어 첫 커밋이 0이 된다 - 그래서 세션 ID를 같이 -# 저장하고, 다르면 0줄부터 다시 센다(GF-139). +# Tokens-Used/Tool-Calls 귀속 기록(decision-27, GF-129). .gitformat-verified와 같은 +# "커밋 간 git-미추적 상태 파일" 관례를 따르되, 이 파일은 삭제되지 않고 세션 동안 +# 계속 자란다. 첫 줄은 세션 ID, 그다음 줄부터는 이미 어느 커밋에 귀속된 응답의 키다 +# (attributed_record_load()/attributed_record_save()). 응답 하나가 두 커밋에 들어가지 +# 않게 하려는 것이라, 세션 ID가 다르면(응답 키는 세션 사이에 유일하다는 보장이 없다) +# 기록을 버리고 새로 시작한다. # 파일명은 gitformat.conf로 빼지 않는다 - 컨슈머가 오버라이드할 이유가 없는 순수 # 내부 구현 세부사항이라 다른 trailer 키 이름들과 성격이 다르다. -CURSOR = os.path.join(GIT_DIR, ".gitformat-token-cursor") +ATTRIBUTED_RECORD = os.path.join(GIT_DIR, ".gitformat-token-attributed") +# GF-139까지 쓰던 델타 커서("<세션 ID> <줄 수>"). 의미가 델타에서 커밋당 귀속으로 +# 바뀌어(decision-27) 더는 읽지 않는다. 남겨 두면 "아직 쓰이는 상태 파일"로 오해받으니 +# 측정에 성공할 때 지운다 - 읽지 않으므로 지우는 시점은 값에 영향이 없다. +LEGACY_CURSOR = os.path.join(GIT_DIR, ".gitformat-token-cursor") TRAILER_VERIFY_BYPASSED = conf_get("gitformat.trailer.verifyBypassed") TRAILER_TASK_ID = conf_get("gitformat.trailer.taskId") @@ -318,33 +322,57 @@ def trailer_ai_model(): queue_trailer(TRAILER_AI_MODEL, ai_model) -USAGE_FIELDS = ( +# in에 합치는 입력측 usage 필드. 캐시 토큰도 모델이 실제로 읽어 들인 입력이라 빼면 +# 값이 무의미해진다(doc-16 실측: 입력측이 전체의 거의 전부다). out은 output_tokens +# 하나다(decision-27, GF-129). +INPUT_USAGE_FIELDS = ( "input_tokens", - "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", ) +OUTPUT_USAGE_FIELD = "output_tokens" -# assistant 턴의 message.usage를 합산하고 tool_use 콘텐츠 블록을 센다. 줄 하나라도 -# JSON 파싱에 실패하면 ValueError를 그대로 올려 배치 전체를 실패시킨다 — 불량 줄만 -# 건너뛰면 "일부만 집계된 값"이 정상값인 척 기록되므로 동작 변경이다. +# JSONL 트랜스크립트 하나를 읽어 (줄 키, 파싱된 줄) 목록으로 돌려준다. 줄 키는 +# "<트랜스크립트 디렉터리 기준 파일 경로>:<1부터 센 줄 번호>"로, requestId/message.id가 +# 없는 줄을 응답으로 삼을 때 쓴다(group_responses()). 트랜스크립트는 뒤에 덧붙기만 +# 하므로 같은 줄은 다음 커밋에서도 같은 키를 얻는다. # -# 트랜스크립트는 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: +# 줄 하나라도 JSON 파싱에 실패하면 ValueError를 그대로 올려 배치 전체를 실패시킨다 — +# 불량 줄만 건너뛰면 "일부만 집계된 값"이 정상값인 척 기록되므로(GF-111/GF-112). +# 파일을 못 읽으면 OSError가 올라간다. 둘 다 호출자가 사유 슬러그로 바꾼다. +def read_transcript_entries(path, key_prefix): + with open(path, "rb") as f: + data = f.read().decode("utf-8", errors="replace") + lines = data.split("\n") + if lines and lines[-1] == "": + lines.pop() + entries = [] + for number, line in enumerate(lines, start=1): if not line.strip(): continue - entry = json.loads(line) + entries.append((f"{key_prefix}:{number}", json.loads(line))) + return entries + + +# assistant 줄을 응답 단위로 묶는다. 트랜스크립트는 API 응답 하나를 content 블록마다 +# 한 줄씩 기록하고 각 줄이 같은 message.usage를 반복해서 들고 있다 - 줄마다 더하면 +# 2~3배 부풀려진다(GF-139 실측: assistant 215줄이 응답 109개). 같은 응답의 줄은 +# 최상위 requestId와 message.id를 공유하므로 " "를 응답 키로 +# 삼는다. 이 키는 그대로 귀속 기록 파일의 한 줄이 되므로, 공백·개행이 든 id는(실제로는 +# 본 적 없다) 기록을 깨뜨리지 않도록 id가 없는 것과 같이 취급한다. +# +# - usage는 응답의 첫 줄 값만 쓴다 - 나머지 줄은 같은 값의 반복이다. +# - tool_use 블록은 모든 줄에서 모은다 - 한 응답의 도구 호출이 여러 줄에 흩어져 있어 +# 첫 줄만 보면 귀속 근거를 놓친다(AC#2). +# - id가 하나라도 없는 줄은 같은 응답인지 판단할 근거가 없으므로 줄 키로 각자 하나의 +# 응답이 된다(GF-139 규칙 유지). +# +# 키가 같으면 파일이 달라도(본 트랜스크립트와 서브에이전트 트랜스크립트) 같은 응답으로 +# 본다 - 같은 API 응답을 두 번 더하지 않는다는 원칙이 파일 경계보다 우선이다. +def group_responses(entries): + responses = {} + for line_key, entry in entries: if not isinstance(entry, dict) or entry.get("type") != "assistant": continue message = entry.get("message") @@ -352,55 +380,204 @@ def aggregate_usage(lines, seen_keys): 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) - tokens += value if isinstance(value, int) else 0 + if ( + isinstance(request_id, str) + and re.fullmatch(r"\S+", request_id) + and isinstance(message_id, str) + and re.fullmatch(r"\S+", message_id) + ): + response_key = f"{request_id} {message_id}" + else: + response_key = line_key + response = responses.get(response_key) + if response is None: + response = {"usage": message.get("usage"), "tool_uses": []} + responses[response_key] = response content = message.get("content") if isinstance(content, list): - calls += sum( - 1 + response["tool_uses"].extend( + block for block in content if isinstance(block, dict) and block.get("type") == "tool_use" ) - return tokens, calls + return responses + + +# 귀속 대상 경로 = 방금 만든 커밋이 바꾼 파일 목록(저장소 상대경로). post-commit은 +# 커밋이 이미 만들어진 뒤에 돌기 때문에 git diff --cached는 비어 있다 - 그래서 HEAD +# 커밋 자체의 파일 목록을 읽는다(AC#8). --root는 첫 커밋(부모 없음)에서도 목록을 +# 내게 하고, -z는 core.quotePath가 비ASCII 경로를 따옴표·이스케이프로 감싸는 것을 +# 막아 원래 경로 그대로 받게 한다. 이름 바꾸기는 -M을 주지 않으므로 옛 이름과 새 +# 이름이 둘 다 나온다 - 어느 쪽을 건드린 응답이든 이 커밋의 작업이다. +def commit_target_paths(): + output = subprocess.run( + [ + "git", + "diff-tree", + "--root", + "--no-commit-id", + "--name-only", + "-r", + "-z", + "HEAD", + ], + stdout=subprocess.PIPE, + encoding="utf-8", + errors="surrogateescape", + check=True, + ).stdout + return {path for path in output.split("\0") if path} + + +# Bash 명령 판정에서 대상 경로 바로 앞에 와도 되는 글자: 공백, 따옴표, 백틱, 셸 +# 구두점(= ( < > | ; & , :). 경로 바로 뒤에는 경로를 이어 쓰는 글자(영숫자 _ . - /)가 +# 오면 안 된다 - hooks/post-commit-old나 a.txt.bak은 다른 파일이다. +BASH_PATH_LEFT_BOUNDARY = "\\s'\"`=(<>|;&,:" +BASH_PATH_RIGHT_FORBIDDEN = "A-Za-z0-9_.\\-/" + + +# 귀속 판정기. 대상 경로와 저장소 최상위 경로를 한 번만 계산해 두고, tool_use 블록 +# 하나가 대상 경로를 실제로 건드렸는지 판정한다(decision-27, GF-129). 판정은 경로 +# 문자열 대조뿐이라 완벽하지 않다 - 경로를 쓰지 않고 파일을 바꾸는 명령(글롭, 변수, +# cd 후 파일명만 쓰기)은 놓치고, 경로를 문자열로 담기만 한 명령(git add, 커밋 +# 메시지에 경로 언급)은 귀속한다. 놓치는 쪽을 택한 것은 오귀속보다 과소 보고가 +# 낫다는 판단이다(doc-16 "판정 규칙"). +class AttributionMatcher: + def __init__(self, targets): + self.targets = targets + self.top = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + stdout=subprocess.PIPE, + encoding="utf-8", + errors="surrogateescape", + check=True, + ).stdout.rstrip("\n") + # macOS의 /tmp → /private/tmp처럼 같은 디렉터리가 두 이름을 가지면, 도구가 + # 기록한 절대경로와 git이 알려준 최상위 경로가 서로 다른 이름을 쓸 수 있다. + # 비교는 양쪽 모두 심볼릭 링크를 푼 물리 경로로 한다. + self.real_top = os.path.realpath(self.top) + self.bash_patterns = [self._bash_pattern(target) for target in sorted(targets)] + + # Bash 명령 문자열에서 대상 경로 하나를 찾는 정규식(AC#4). + # + # - 하위 디렉터리 안의 대상(hooks/post-commit): 저장소 상대경로가 경계로 구분돼 + # 들어 있으면 귀속한다. 왼쪽 경계에 '/'도 허용한다 - 그래야 절대경로 + # (/…/git-format/hooks/post-commit)나 ../git-format/hooks/post-commit처럼 + # 상대경로를 꼬리로 품은 표기도 잡힌다. myhooks/post-commit은 왼쪽이 영문자라 + # 걸리지 않는다. 파일명(post-commit)만 든 명령은 상대경로가 없으니 귀속하지 + # 않는다 - README.md 같은 흔한 이름이 무관한 응답을 끌어오는 것을 막는다. + # - 저장소 최상위의 대상(a.txt): 상대경로가 곧 파일명이다. 그래서 왼쪽 경계에 + # '/'를 허용하지 않는다 - 허용하면 sub/a.txt 같은 다른 파일의 파일명 매칭이 + # 된다. 대신 ./a.txt와 저장소 최상위 절대경로(논리·물리 둘 다)를 붙인 표기는 + # 명시적으로 받는다. + def _bash_pattern(self, target): + right = rf"(?![{BASH_PATH_RIGHT_FORBIDDEN}])" + if "/" in target: + left = rf"(?/subagents/*.jsonl)를 훑어 아직 귀속되지 않은 응답 중 +# 이 커밋이 바꾼 파일을 건드린 것을 고른다 - "a 수정 → b 먼저 커밋 → a 커밋" 순서에서도 +# a를 고친 응답이 뒤 커밋에 들어간다. 응답 하나는 한 커밋에만 귀속되도록, 귀속한 응답 +# 키를 ATTRIBUTED_RECORD에 남긴다. 이 커밋의 파일을 건드리지 않은 응답(무관한 파일 +# 탐색, 도구 호출 없는 대화)은 어느 커밋에도 들어가지 않는다 - 커밋당 토큰량이라는 +# 정의에서 의도된 결과다. Read도 file_path를 가지므로 커밋한 파일을 읽은 응답은 +# 귀속된다. # -# 주의(README/decision-5 참고): 이 함수가 계산하는 값은 "이 커밋을 만드는 데 -# 정확히 필요했던 토큰"이 아니라 "직전 커밋 이후 이 세션에서 소비된 토큰"의 -# 근사치다 - 그 구간에 이 커밋과 무관한 대화/탐색이 섞이면 과대평가될 수 있다. +# 성공하면 MEASUREMENT_STATUS=ok와 INPUT_TOKENS_SUM/OUTPUT_TOKENS_SUM/TOOL_CALL_COUNT/ +# ATTRIBUTED_RESPONSE_COUNT를 채운다. 실패하면 MEASUREMENT_STATUS=unavailable + +# MEASUREMENT_REASON(실패 지점을 가리키는 짧은 슬러그)만 남기고 기록 파일도 갱신하지 +# 않는다 - 다음 커밋이 같은 응답들을 다시 판정할 수 있어야 한다. 실패 여부/사유는 +# 예외가 아니라 이 상태 변수들로만 전달한다. MEASUREMENT_STATUS = "unavailable" MEASUREMENT_REASON = "" -TOKENS_SUM = 0 +INPUT_TOKENS_SUM = 0 +OUTPUT_TOKENS_SUM = 0 TOOL_CALL_COUNT = 0 +ATTRIBUTED_RESPONSE_COUNT = 0 def measure_claude_code_token_usage(): - global MEASUREMENT_STATUS, MEASUREMENT_REASON, TOKENS_SUM, TOOL_CALL_COUNT + global MEASUREMENT_STATUS, MEASUREMENT_REASON, INPUT_TOKENS_SUM, OUTPUT_TOKENS_SUM + global TOOL_CALL_COUNT, ATTRIBUTED_RESPONSE_COUNT MEASUREMENT_STATUS = "unavailable" MEASUREMENT_REASON = "" - if not os.environ.get("CLAUDE_CODE_SESSION_ID"): + session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "") + if not session_id: MEASUREMENT_REASON = "no-session-id" return @@ -409,62 +586,77 @@ def measure_claude_code_token_usage(): MEASUREMENT_REASON = "transcript-not-found" return - try: - with open(transcript, "rb") as f: - data = f.read().decode("utf-8", errors="replace") - except OSError: - MEASUREMENT_REASON = "transcript-unreadable" - return - - # 커서 값은 `wc -l`과 동일하게 개행 문자 개수로 센다 - 마지막 줄에 개행이 없는 - # 파일을 하나 적게 세는 것까지 같아야 이전 커밋이 남긴 커서와 어긋나지 않는다. - total_lines = data.count("\n") - - lines = data.split("\n") - if lines and lines[-1] == "": - lines.pop() + # 서브에이전트 트랜스크립트는 Claude Code가 본 트랜스크립트 옆 + # <세션 ID>/subagents/에 같은 JSONL 형식으로 남긴다(AC#11). 서브에이전트를 쓰지 + # 않은 세션에는 이 디렉터리가 없으므로 없는 건 정상이다. 정렬은 줄 키와 응답 순서를 + # 실행마다 같게 하려는 것이다. 줄 키의 파일 부분은 트랜스크립트 디렉터리 기준 경로라 + # 본 트랜스크립트와 서브에이전트 파일이 서로 겹치지 않는다. + sources = [(transcript, os.path.basename(transcript))] + subagent_dir = os.path.join(os.path.dirname(transcript), session_id, "subagents") + if os.path.isdir(subagent_dir): + try: + names = sorted(os.listdir(subagent_dir)) + except OSError: + MEASUREMENT_REASON = "transcript-unreadable" + return + for name in names: + path = os.path.join(subagent_dir, name) + if name.endswith(".jsonl") and os.path.isfile(path): + sources.append((path, f"{session_id}/subagents/{name}")) + + # 한 파일, 한 줄이라도 깨지면 배치 전체가 실패한다 - 일부만 집계한 값을 정상값처럼 + # 남기지 않는다(GF-112). 이제는 구간이 아니라 세션 전체를 매번 읽으므로, 한 번 + # 깨진 줄이 생기면 그 세션의 이후 커밋은 계속 transcript-parse-failed가 된다 - + # 조용히 틀린 값을 남기는 것보다 사유가 계속 보이는 쪽을 택했다. + entries = [] + for path, key_prefix in sources: + try: + entries += read_transcript_entries(path, key_prefix) + except OSError: + MEASUREMENT_REASON = "transcript-unreadable" + return + except (ValueError, TypeError): + MEASUREMENT_REASON = "transcript-parse-failed" + return + + responses = group_responses(entries) + matcher = AttributionMatcher(commit_target_paths()) + recorded = attributed_record_load(session_id) + already_attributed = set(recorded) + + input_sum = 0 + output_sum = 0 + calls = 0 + new_keys = [] + for response_key, response in responses.items(): + if response_key in already_attributed: + continue + # Tool-Calls는 귀속된 응답의 tool_use 전부가 아니라 대상 경로를 실제로 건드린 + # 블록만 센다(AC#6) - 같은 응답 안의 ls·grep 같은 호출은 이 커밋의 작업이 아니다. + touching = sum(1 for block in response["tool_uses"] if matcher.touches(block)) + if not touching: + continue + new_keys.append(response_key) + calls += touching + usage = response["usage"] + if isinstance(usage, dict): + for field in INPUT_USAGE_FIELDS: + value = usage.get(field) + input_sum += value if isinstance(value, int) else 0 + value = usage.get(OUTPUT_USAGE_FIELD) + output_sum += value if isinstance(value, int) else 0 - # 커서는 "<세션 ID> <줄 수>" 한 줄이다(GF-139). 파일이 없거나(최초 실행) - # 손상됐거나, 숫자만 든 예전 형식이거나, 세션 ID가 지금 세션과 다르면 0으로 - # 취급한다 - 이건 조회 자체의 실패가 아니라 "이 세션의 트랜스크립트는 아직 아무 - # 줄도 처리한 적 없음"과 동치이므로 실패로 취급하지 않는다. 세션 ID는 마지막 - # 공백 기준으로 잘라 비교한다(줄 수 쪽엔 공백이 없다). - session_id = os.environ["CLAUDE_CODE_SESSION_ID"] - cursor_count = 0 + # 여기까지 왔으면 트랜스크립트를 실제로 읽어 판정했으므로, 귀속된 응답이 없어도 + # 기록을 쓴다 - 세션이 바뀐 직후라면 첫 줄(세션 ID)을 새 세션으로 맞춰 둔다. + attributed_record_save(session_id, recorded + new_keys) try: - with open(CURSOR, "rb") as f: - raw_cursor = f.read().decode("utf-8", errors="replace").rstrip("\n") + os.remove(LEGACY_CURSOR) except OSError: - 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: - 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(f"{session_id} {total_lines}") + pass + INPUT_TOKENS_SUM = input_sum + OUTPUT_TOKENS_SUM = output_sum + TOOL_CALL_COUNT = calls + ATTRIBUTED_RESPONSE_COUNT = len(new_keys) MEASUREMENT_STATUS = "ok" @@ -472,17 +664,25 @@ def measure_claude_code_token_usage(): # (AI_TOOL_ID가 빈 값)은 트레일러를 전혀 붙이지 않는다. claude-code는 # measure_claude_code_token_usage()의 실측 결과를 쓴다. 그 외 감지된 AI 도구는 # 아직 서버발급 usage를 읽을 로컬 채널이 없으므로(decision-15) 항상 -# "unavailable (no-usage-channel)"을 남긴다. 측정해서 나온 0은 "unavailable"과 -# 구분되는 정당한 값이므로 0 초과 가드 없이 그대로 기록한다. +# "unavailable (no-usage-channel)"을 남긴다. +# +# 측정에 성공한 값은 두 가지로 나뉜다(decision-27). 귀속된 응답이 있으면 +# "in=<입력> out=<출력>"이고, 그 usage가 정말 0이어도 사유 없이 그대로 남긴다 - +# 측정해서 나온 0은 "unavailable"과 구분되는 정당한 값이다. 귀속된 응답이 하나도 +# 없으면 같은 0이라도 "이 커밋 파일을 건드린 응답을 못 찾았다"는 뜻이므로 +# (no-attributed-turn)을 붙여 진짜 측정값 0과 footer만 보고 구별되게 한다(AC#12). def trailer_tokens_used(): if not AI_TOOL_ID: return if AI_TOOL_ID == AI_TOOL_CLAUDE_CODE: measure_claude_code_token_usage() - if MEASUREMENT_STATUS == "ok": - tokens_value = str(TOKENS_SUM) + if MEASUREMENT_STATUS == "ok" and ATTRIBUTED_RESPONSE_COUNT: + tokens_value = f"in={INPUT_TOKENS_SUM} out={OUTPUT_TOKENS_SUM}" calls_value = str(TOOL_CALL_COUNT) + elif MEASUREMENT_STATUS == "ok": + tokens_value = "in=0 out=0 (no-attributed-turn)" + calls_value = "0 (no-attributed-turn)" else: tokens_value = f"unavailable ({MEASUREMENT_REASON})" calls_value = f"unavailable ({MEASUREMENT_REASON})" From 1b45cc30d6cddddbf7c7492c8b1166d430024c88 Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:31:03 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[docs]=20=EC=BB=A4=EB=B0=8B=EB=8B=B9=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EB=9F=89=20=EB=AC=B8=EC=84=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=5067994 out=15794 Tool-Calls: 31 Hooks-Commit: bc5e9d8 Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- README.md | 9 +- ...0-\352\260\200\354\235\264\353\223\234.md" | 4 +- ...5\352\263\274-\355\225\234\352\263\204.md" | 167 +++++++++++------- ...4\353\217\274-\354\236\210\353\202\230.md" | 8 +- ...10\355\215\274\353\237\260\354\212\244.md" | 27 +-- 5 files changed, 133 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 6ea6e2c..1e92570 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ AI-Tool: claude-code AI-Tool-Version: 2.1.267 Co-Authored-By: Claude AI-Model: claude-opus-5 -Tokens-Used: 1952818 +Tokens-Used: in=1952818 out=8402 Tool-Calls: 2 Hooks-Commit: c828491 Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> @@ -63,8 +63,8 @@ Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> | `Task-Id` | 이 커밋이 어느 작업의 일부인지. 커밋과 태스크를 잇습니다 | `post-commit` · 브랜치명의 `-<번호>`(decision-4) | 자동 | | `AI-Tool`
`AI-Tool-Version` | 어떤 도구가 커밋을 만들었는지 | `post-commit` · `AI_AGENT` 환경변수(Claude Code가 하위 프로세스에 주입) | 강제 — LLM이 스스로 만든 값이 아님 | | `AI-Model` | 어떤 모델이 썼는지. 모델별 작업 품질을 나중에 비교할 수 있게 합니다 | `post-commit` · Claude Code는 세션 트랜스크립트의 `message.model`, 그 외는 `gitformat.aiModel` 설정값 | Claude Code는 서버 발급 사실 / 그 외는 자가신고 | -| `Tokens-Used` | 이 커밋에 든 토큰. 작업 비용을 이력에서 읽게 합니다 | `post-commit` · 직전 커밋 이후 세션 구간의 델타(누적 아님) | 서버 발급 사실(Claude Code 한정), **측정 방법론은 실험 단계** | -| `Tool-Calls` | 같은 구간의 도구 호출 수 | `post-commit` · `tool_use` 블록 개수 | 위와 동일 | +| `Tokens-Used` | 이 커밋에 든 토큰(`in=<입력> out=<출력>`). 작업 비용을 이력에서 읽게 합니다 | `post-commit` · 이 커밋이 바꾼 파일을 건드린 응답의 토큰 합(귀속), 델타 아님(decision-27) | 서버 발급 사실(Claude Code 한정), **측정 방법론은 실험 단계** | +| `Tool-Calls` | 이 커밋의 파일을 건드린 도구 호출 수 | `post-commit` · 귀속된 응답에서 대상 파일을 건드린 `tool_use` 블록 개수 | 위와 동일 | | `Co-Authored-By` | 공동저자 귀속 | `post-commit` · `AI-Tool`이 `claude-code`일 때만 | 자동 | | `Hooks-Commit` | 이 커밋을 검사한 git-trail 자체의 버전. 훅에 버그가 있었을 때 어느 커밋들이 그 훅을 거쳤는지 역추적합니다 | `post-commit` · 훅 클론의 `rev-parse --short HEAD` | 완전 자동, 모든 커밋 | | `Signed-off-by` | 커미터 정보(DCO 관례) | `post-commit` · `git log -1 --format='%cn <%ce>'` | 완전 자동, 모든 커밋 | @@ -73,7 +73,8 @@ Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> `Tokens-Used`/`Tool-Calls`는 측정에 실패하면 사유와 함께 `unavailable (사유)`로 남습니다 — `no-session-id`, `transcript-not-found`, `transcript-unreadable`, `transcript-parse-failed`, -`no-usage-channel`. +`no-usage-channel`. 이 커밋의 파일을 건드린 응답이 없으면 0과 함께 `(no-attributed-turn)`이 +붙습니다(`Tokens-Used: in=0 out=0 (no-attributed-turn)`). ## 🪝 커밋 한 번에 훅이 도는 순서 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 7a763b1..1f8e62f 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:44' +updated_date: '2026-10-03 07:30' --- ## 🚀 설치 @@ -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` 델타 계산용 커서(`<세션 ID> <줄 수>`, 세션이 바뀌면 0줄부터, GF-139), 커밋 간 유지됨 | +| `.gitformat-token-attributed` | `<대상 저장소>/.git/` | Claude Code 실측 성공 시에만 `post-commit`이 갱신(`unavailable`일 때는 갱신 안 함) | `Tokens-Used`/`Tool-Calls` 귀속 기록(첫 줄 세션 ID, 이후 이미 귀속된 응답 키 — 세션이 바뀌면 새로 시작, GF-129), 커밋 간 유지됨. 예전 델타 커서 `.gitformat-token-cursor`는 더 쓰지 않으며 측정 성공 시 지워짐 | | `template/hooks/*` | 이 git-trail 클론 자신의 `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 57f4c69..502c583 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-10-03 03:39' +updated_date: '2026-10-03 07:30' --- # 토큰·툴콜 측정 방법과 한계 @@ -33,98 +33,143 @@ apiBlockIndex=3 content=['tool_use'] usage합=87932 3. 그룹의 `tool_use` 블록은 **모든 줄에서 모은다**(한 응답의 도구 호출이 여러 줄에 흩어져 있다). 이 목록으로 귀속 여부를 판정한다 -## 현재 post-commit 구현 (GF-139 핫픽스) +## 현재 post-commit 구현 (GF-129, decision-27) -아래 "귀속 필터"와 "기록 형식"은 GF-129가 `prepare-commit-msg`로 옮기며 들일 목표 설계다. -지금 `post-commit`은 귀속 필터 없이 직전 커밋 이후 구간 전체를 합산해 `Tokens-Used: ` -한 값으로 남긴다. 다만 두 가지는 GF-139에서 먼저 고쳤다. +`Tokens-Used`는 **이 커밋에 귀속된 응답의 토큰 합**이다. 직전 커밋 이후 구간 전체(델타)는 +어디에도 세거나 기록하지 않는다(decision-27이 decision-19의 `delta` 조항을 대체). 구현은 +`hooks/post-commit`의 `measure_claude_code_token_usage()`다. `prepare-commit-msg`로 옮기는 +일은 GF-128에 남아 있다. -- **응답 단위 계상.** 위 알고리즘 1·2단계를 그대로 적용한다. `(requestId, message.id)`가 - 같은 assistant 줄은 `usage`를 한 번만 더하고, 둘 중 하나라도 없는 줄은 줄 단위로 더한다. - `Tool-Calls`는 모든 줄의 `tool_use` 블록을 센다. -- **세션별 커서.** `.git/.gitformat-token-cursor`는 `<세션 ID> <줄 수>` 한 줄이다(개행 없음). - 트랜스크립트는 세션마다 새 파일이라 줄 수만 저장하면 새 세션의 짧은 파일을 이전 세션의 - 줄 수로 읽어 첫 커밋이 0이 됐다. 세션 ID가 다르거나, 숫자만 든 예전 형식이거나, 손상됐으면 - 0줄부터 센다. -- **커밋 경계를 걸친 응답.** 응답 하나의 줄이 커서 앞뒤로 나뉘면 앞 커밋이 이미 그 `usage`를 - 더했다. 그래서 같은 세션이면 커서 앞 구간의 응답 키를 먼저 모아 두고, 뒤 커밋에서는 그 - 키의 `usage`를 다시 더하지 않는다. +1. **후보 응답.** 세션 트랜스크립트 전체와 `<트랜스크립트 디렉터리>/<세션 ID>/subagents/*.jsonl` + (서브에이전트 트랜스크립트, 이름순)을 읽는다. 직전 커밋 이후 구간이 아니라 세션 전체다 — + 그래야 "a 수정 → b 먼저 커밋 → a 커밋" 순서에서 a를 고친 응답을 놓치지 않는다. +2. **응답 단위로 묶기.** 위 알고리즘 그대로다. `(requestId, message.id)`가 같은 줄은 한 응답이고, + `usage`는 첫 줄 값을 한 번만, `tool_use`는 모든 줄에서 모은다. id가 하나라도 없는 줄은 각자 + 하나의 응답이다(GF-139 규칙). 키가 같으면 파일이 달라도 같은 응답으로 본다. +3. **대상 경로.** 방금 만든 커밋이 바꾼 파일 목록 — + `git diff-tree --root --no-commit-id --name-only -r -z HEAD`. `post-commit`은 커밋이 이미 + 만들어진 뒤라 `git diff --cached`는 비어 있다. +4. **귀속 판정.** 응답의 `tool_use` 중 하나라도 대상 경로를 건드렸으면 귀속한다(아래 "판정 규칙"). +5. **한 응답은 한 커밋에만.** 귀속한 응답 키를 `.git/.gitformat-token-attributed`에 남기고, 다음 + 커밋은 이 파일에 있는 응답을 건너뛴다. -형식 변경(`in`/`out`/`delta`)과 귀속 필터는 GF-129에 남아 있다. GF-129는 위 세 가지를 -`prepare-commit-msg`로 옮길 때 함께 가져가야 한다. +### 귀속 기록 파일 `.git/.gitformat-token-attributed` -## 귀속 필터 +``` +<세션 ID> + + +<파일>:<줄 번호> +``` + +- 첫 줄은 세션 ID, 그다음은 이미 귀속된 응답 키다. id가 없는 줄의 키는 트랜스크립트 디렉터리 + 기준 파일 경로와 1부터 센 줄 번호다(예: `<세션 ID>.jsonl:12`, + `<세션 ID>/subagents/agent-1.jsonl:3`). 트랜스크립트는 뒤에 덧붙기만 하므로 키가 바뀌지 않는다. +- 첫 줄이 지금 세션과 다르거나 파일이 없거나 손상됐으면 기록을 새로 시작한다. 응답 키가 세션 + 사이에 유일하다는 보장이 없어서다. +- 측정에 성공했을 때만 쓴다(귀속된 응답이 없어도 쓴다). 실패하면 그대로 둔다. + +### 과거: 델타 커서 (GF-96 ~ GF-139, 폐기) + +GF-129 이전에는 `.git/.gitformat-token-cursor`(`<세션 ID> <줄 수>`)에 이미 읽은 줄 수를 두고, +직전 커밋 이후 구간 전체를 귀속 필터 없이 더해 `Tokens-Used: ` 한 값으로 남겼다. GF-139는 +여기에 응답 단위 계상, 세션별 커서, 커서 경계를 걸친 응답의 중복 방지를 더했다. 커서 파일은 +이제 읽지 않으며, 측정에 성공하면 `post-commit`이 지운다. 그 시절 커밋의 `Tokens-Used`는 단일 +숫자라 새 형식(`in=… out=…`)과 구별된다. 고치지 않는다. -쌓인 턴 중 **이 커밋과 관계있는 턴만 골라내는 체**다. 기준은 하나 — 그 턴의 도구 호출이 -이 커밋에 스테이징된 파일을 건드렸는가. +## 귀속 필터 -대상 경로는 `git diff --cached --name-only`로 얻는다. `prepare-commit-msg`는 커밋 전에 -돌기 때문에 스테이징 목록이 정확히 이 커밋의 내용이다. +쌓인 응답 중 **이 커밋과 관계있는 응답만 골라내는 체**다. 기준은 하나 — 그 응답의 도구 호출이 +이 커밋이 바꾼 파일을 건드렸는가. ### 예시 -스테이징 파일이 `hooks/prepare-commit-msg` 하나이고 다섯 턴이 쌓인 경우: +커밋한 파일이 `hooks/post-commit` 하나이고 세션에 다섯 응답이 있는 경우: -| 턴 | 도구 호출의 `input` | in | out | 필터 | +| 응답 | 도구 호출의 `input` | in | out | 필터 | | --- | --- | --- | --- | --- | -| 1 | `Grep {pattern:"trailer"}` | 120,000 | 800 | 탈락 — 경로가 없다 | -| 2 | `Read {file_path:"hooks/post-commit"}` | 200,000 | 1,200 | 탈락 — 스테이징 안 된 파일 | -| 3 | `Write {file_path:"hooks/prepare-commit-msg"}` | 210,000 | 5,000 | **통과** — 완전 일치 | -| 4 | `Bash {command:"ruff check hooks/prepare-commit-msg"}` | 30,000 | 400 | **통과** — 명령에 경로가 있다 | +| 1 | `Grep {pattern:"trailer"}` | 120,000 | 800 | 탈락 — 건드린 경로가 없다 | +| 2 | `Read {file_path:"hooks/commit-msg"}` | 200,000 | 1,200 | 탈락 — 이 커밋의 파일이 아니다 | +| 3 | `Edit {file_path:"/…/git-trail/hooks/post-commit"}` | 210,000 | 5,000 | **통과** — 정규화 후 완전 일치 | +| 4 | `Bash {command:"ruff check hooks/post-commit"}` | 30,000 | 400 | **통과** — 명령에 경로가 있다 | | 5 | 설계 논의 (도구 호출 0개) | 180,000 | 2,000 | 탈락 — 매칭할 입력이 없다 | -결과: `Tokens-Used: in=240000 out=5400 delta=749400`, `Tool-Calls: 2`. -차이 504,000이 귀속하지 못한 양이다. - -### 판정 규칙 — 정확한 경로 비교만 쓴다 - -- `file_path`/`notebook_path` 필드를 가진 도구(Read/Edit/Write 등) → 저장소 상대경로로 - 정규화해 **완전 일치**. 오귀속이 원리적으로 불가능하다. -- Bash `command` → 명령 문자열에 저장소 상대경로가 들어 있는지만 본다. **파일명이나 파일명 - 앞토큰으로는 매칭하지 않는다** — `README.md` 같은 흔한 이름이나 `a.txt` → `a` 같은 짧은 - 토큰이 무관한 턴을 끌어오는 것을 막기 위해서다. +결과: `Tokens-Used: in=240000 out=5400`, `Tool-Calls: 2`. 응답 1·2·5는 어느 커밋에도 들어가지 +않는다 — 커밋당 토큰량이라는 정의에서 의도된 결과다(decision-27). + +### 판정 규칙 — 경로 문자열 대조만 쓴다 + +- `file_path`/`notebook_path` 필드를 가진 도구(Read/Edit/Write/NotebookEdit 등) → 저장소 + 상대경로로 정규화해 **완전 일치**. 상대경로는 저장소 최상위 기준이다. 절대경로는 디렉터리 + 부분의 심볼릭 링크를 풀어(`/tmp` → `/private/tmp` 같은) 최상위의 물리 경로와 비교한다. 파일 + 자체가 링크면 링크 이름을 유지한다(커밋에는 링크 이름으로 들어간다). 저장소 밖 경로는 탈락. + Read도 여기에 해당하므로 커밋한 파일을 읽은 응답은 귀속된다. +- Bash `command` → 명령 문자열에 대상 경로가 **경계로 구분돼** 들어 있는지만 본다. + - 경로 앞: 문자열 시작, 공백, 따옴표, 백틱, 셸 구두점(`= ( < > | ; & , :`). + - 경로 뒤: 문자열 끝, 또는 경로를 이어 쓰는 글자(영숫자 `_ . - /`)가 아닌 것. + `hooks/post-commit-old`, `a.txt.bak`는 다른 파일이다. + - 하위 디렉터리 안의 파일(`hooks/post-commit`)은 경로 앞에 `/`도 허용한다 — 절대경로나 + `../git-trail/hooks/post-commit`도 잡힌다. `myhooks/post-commit`은 잡히지 않는다. + - **파일명이나 파일명 앞토큰으로는 매칭하지 않는다** — `cd hooks && cat post-commit`은 귀속되지 + 않는다. `README.md` 같은 흔한 이름이나 `a.txt` → `a` 같은 짧은 토큰이 무관한 응답을 끌어오는 + 것을 막기 위해서다. + - 최상위 파일(`a.txt`)은 상대경로가 곧 파일명이라 경계로 구분된 이름을 받되, 앞에 `/`는 + 허용하지 않는다(`sub/a.txt`는 다른 파일). 대신 `./a.txt`와 저장소 최상위 절대경로를 붙인 + 표기는 받는다. +- 그 밖의 도구(Grep/Glob 등)는 귀속하지 않는다. 경로 인자가 있어도 검색 범위일 뿐이다. ## 기록 형식 -`Tokens-Used: in= out= delta=` — 한 줄에 세 값을 묶는다. +`Tokens-Used: in= out=` — 한 줄에 두 값을 묶는다. | 값 | 정의 | | --- | --- | -| `in` | 귀속된 턴의 입력측 합 = `input_tokens` + `cache_creation_input_tokens` + `cache_read_input_tokens` | -| `out` | 귀속된 턴의 `output_tokens` 합 | -| `delta` | 직전 커밋 이후 구간 **전체** 합(귀속 필터 없음). `in+out`과의 차이가 귀속되지 못한 양 | - -`delta`를 함께 남기는 이유: 귀속 필터는 정밀도를 위해 재현율을 버리므로 반드시 과소 보고된다. -문서에만 적어두면 로그를 읽는 쪽이 알 수 없다. 두 값을 나란히 쓰면 **커밋 자체가 측정의 -정밀도를 드러낸다.** +| `in` | 귀속된 응답의 입력측 합 = `input_tokens` + `cache_creation_input_tokens` + `cache_read_input_tokens` | +| `out` | 귀속된 응답의 `output_tokens` 합 | 캐시 토큰은 `in`에 합쳤다. 실측 비율은 in 9,821,179 / out 143,129로 거의 전부가 입력측이다 — 캐시를 빼면 값이 무의미해진다. 분리가 필요하면 `cache=`를 덧붙이면 된다. -`usage`는 응답 단위로만 존재해 파일별로 쪼갤 수 없으므로 `in`/`out`의 최소 단위는 턴이다. -`Tool-Calls`는 대상 경로를 실제로 건드린 `tool_use` **블록 개수**로 더 좁게 센다. +`usage`는 응답 단위로만 존재해 파일별로 쪼갤 수 없으므로 `in`/`out`의 최소 단위는 응답이다. +`Tool-Calls`는 귀속된 응답의 `tool_use` 중 대상 경로를 실제로 건드린 **블록 개수**로 더 좁게 센다. + +과거 설계(decision-19)는 `delta=<구간 전체>`를 함께 남겨 귀속 정밀도를 드러내려 했다. +decision-27로 폐기했다 — 델타는 커밋의 비용이 아니라 커밋 간격을 잰다. ## 측정 실패를 0으로 위장하지 않는다 | 상황 | 기록 | | --- | --- | -| 정상 | `Tokens-Used: in=240000 out=5400 delta=749400` | -| 구간에 턴이 아예 없음(진짜 0) | `Tokens-Used: in=0 out=0 delta=0` | -| 턴은 있으나 귀속된 게 없음 | `Tokens-Used: in=0 out=0 delta=749400 (no-attributed-turn)` | -| 트랜스크립트를 못 읽음 | `Tokens-Used: unavailable (transcript-not-found)` 등 사유 슬러그 | +| 정상 | `Tokens-Used: in=240000 out=5400`, `Tool-Calls: 2` | +| 귀속된 응답의 usage가 정말 0 | `Tokens-Used: in=0 out=0`, `Tool-Calls: ` | +| 귀속된 응답이 하나도 없음 | `Tokens-Used: in=0 out=0 (no-attributed-turn)`, `Tool-Calls: 0 (no-attributed-turn)` | +| 트랜스크립트를 못 읽음 | `unavailable (사유)` — `no-session-id`, `transcript-not-found`, `transcript-unreadable`, `transcript-parse-failed` | +| Claude Code 외 도구 | `unavailable (no-usage-channel)` | + +세 번째 행이 핵심이다 — `(no-attributed-turn)`이 붙어 "토큰을 안 썼다"와 "이 커밋 파일을 건드린 +응답을 못 찾았다"가 footer만 봐도 구별된다. -세 번째 행이 핵심이다 — `delta`가 살아 있으므로 "토큰을 안 썼다"와 "귀속에 실패했다"가 -footer만 봐도 구별된다. +트랜스크립트(본 파일이나 서브에이전트 파일)에 깨진 줄이 하나라도 있으면 배치 전체가 +`transcript-parse-failed`다(일부만 집계한 값을 정상값처럼 남기지 않는다, GF-112). 이제 매번 세션 +전체를 읽으므로, 깨진 줄이 한 번 생기면 그 세션의 이후 커밋도 계속 이 사유가 된다. ## 한계 -- **서브에이전트 토큰은 부모 트랜스크립트에 없다.** 실측으로 assistant 줄의 `isSidechain`이 - 전부 `false`이고 sidechain 토큰 합이 0이다. 위임한 작업의 비용은 어떤 방식으로도 이 - 지표에 잡히지 않는다. -- 파일명을 말하지 않는 도구 호출(`bats tests/`, `backlog task edit GF-116`)과 도구를 아예 - 쓰지 않은 턴(설계·논의)은 빠진다. 다만 손실은 걱정보다 작다 — 실측으로 57개 응답 중 - 도구를 쓰지 않은 것은 3개(5%)다(줄 단위로 세면 57%로 보이지만 쪼개짐 때문에 생긴 착시다). -- 한 턴에서 커밋을 둘 연달아 만들면 먼저 나온 커밋이 그 구간을 가져간다. +- **경로 문자열 대조라 완벽하지 않다.** 경로를 쓰지 않고 파일을 바꾸는 명령(글롭, 변수, + `cd` 후 파일명만 쓰기, `sed -i` 대상이 글롭)은 놓친다. 반대로 경로를 문자열로 담기만 한 + 명령(`git add hooks/post-commit`, 커밋 메시지에 경로 언급)은 귀속한다. 놓치는 쪽을 택한 것은 + 오귀속보다 과소 보고가 낫다는 판단이다. +- **탐색 비용은 기록되지 않는다.** 어느 커밋의 파일도 건드리지 않은 응답(무관한 파일 읽기·검색, + 도구 없는 설계 논의)은 어느 트레일러에도 들어가지 않으므로, 트레일러를 다 더해도 세션 총량이 + 나오지 않는다(decision-27). 실측으로 도구를 쓰지 않은 응답은 57개 중 3개(5%)였다(줄 단위로 세면 + 57%로 보이지만 쪼개짐 때문에 생긴 착시다). +- **먼저 커밋된 쪽이 응답을 가져간다.** 한 응답이 a와 b를 함께 고쳤는데 a만 먼저 커밋하면 그 + 응답 전체가 a 커밋에 들어가고, b 커밋에는 들어가지 않는다. +- **`--amend`는 같은 응답을 다시 세지 않는다.** 원래 커밋이 이미 귀속한 응답은 기록 파일에 있어, + amend한 커밋에는 새로 생긴 응답만 들어간다(없으면 `no-attributed-turn`). +- 서브에이전트 토큰은 부모 트랜스크립트에 없다(실측으로 부모의 assistant 줄 `isSidechain`이 전부 + `false`, sidechain 토큰 합 0). 그래서 `<세션 ID>/subagents/*.jsonl`을 따로 읽는다(GF-129). 이 + 레이아웃도 문서화되지 않은 내부 구현이라, 바뀌면 서브에이전트 몫이 조용히 빠진다. - **`file-history-snapshot`은 쓸 수 없다.** 파일 편집을 정확히 기록하는 구조처럼 보이지만, 실측으로 `trackedFileBackups`가 전부 비어 있다(한 세션 3건, 파일을 많이 고친 다른 세션 28건 모두). 더 정확한 귀속 재료를 찾는다면 여기서 다시 시작하지 말 것. diff --git "a/backlog/docs/doc-21 - \352\260\234\353\205\220-\354\247\200\353\217\204-\342\200\224-trail\302\267provenance\302\267attestation\302\267audit\354\235\264-\354\226\264\353\224\224\354\227\220-\352\265\254\355\230\204\353\217\274-\354\236\210\353\202\230.md" "b/backlog/docs/doc-21 - \352\260\234\353\205\220-\354\247\200\353\217\204-\342\200\224-trail\302\267provenance\302\267attestation\302\267audit\354\235\264-\354\226\264\353\224\224\354\227\220-\352\265\254\355\230\204\353\217\274-\354\236\210\353\202\230.md" index fe9f052..ff2afa7 100644 --- "a/backlog/docs/doc-21 - \352\260\234\353\205\220-\354\247\200\353\217\204-\342\200\224-trail\302\267provenance\302\267attestation\302\267audit\354\235\264-\354\226\264\353\224\224\354\227\220-\352\265\254\355\230\204\353\217\274-\354\236\210\353\202\230.md" +++ "b/backlog/docs/doc-21 - \352\260\234\353\205\220-\354\247\200\353\217\204-\342\200\224-trail\302\267provenance\302\267attestation\302\267audit\354\235\264-\354\226\264\353\224\224\354\227\220-\352\265\254\355\230\204\353\217\274-\354\236\210\353\202\230.md" @@ -3,7 +3,7 @@ id: doc-21 title: 개념 지도 — trail·provenance·attestation·audit이 어디에 구현돼 있나 type: specification created_date: '2026-10-03 03:46' -updated_date: '2026-10-03 07:14' +updated_date: '2026-10-03 07:30' --- git-trail이라는 이름(decision-26)과 설명에 쓰는 네 용어가 이 저장소에서 실제로 무엇을 가리키는지 정리한다. 용어마다 **뜻 → 구현 위치 → 빈 곳** 순서다. 구현 위치는 함수 이름과 파일 경로로 적는다 @@ -52,7 +52,7 @@ git-trail에서는 커밋마다 붙는 출처 트레일러가 provenance다. | `Task-Id` | `trailer_task_id()` | 브랜치명의 `-<번호>` | `hooks/commit-msg`의 `enforce_task_id_branch()` — 패턴이 없으면 거부. 예외 브랜치(main/master/develop/release/*)·detached HEAD·병합 중은 면제(decision-4) | | `AI-Tool`, `AI-Tool-Version`, `Co-Authored-By` | `trailer_ai_tool()` | `AI_AGENT` 환경변수(Claude Code가 주입) | — | | `AI-Model` | `trailer_ai_model()` → `read_transcript_model()`, `claude_transcript_path()` | Claude Code: 세션 트랜스크립트의 `message.model` / 그 외: `gitformat.aiModel` 설정 | `hooks/commit-msg`의 `enforce_ai_model_gate()` — Claude Code 외 도구는 `gitformat.knownModel` 목록에 있어야 함 | -| `Tokens-Used`, `Tool-Calls` | `trailer_tokens_used()` → `measure_claude_code_token_usage()`, `aggregate_usage()` | 트랜스크립트 `message.usage`를 응답 단위로 합산, 커서 `/.gitformat-token-cursor`(GF-139) | — | +| `Tokens-Used`, `Tool-Calls` | `trailer_tokens_used()` → `measure_claude_code_token_usage()` → `read_transcript_entries()`, `group_responses()`, `commit_target_paths()`, `AttributionMatcher`, `attributed_record_load()`/`attributed_record_save()` | 세션·서브에이전트 트랜스크립트에서 이 커밋이 바꾼 파일을 건드린 응답의 `message.usage`를 응답 단위로 합산(커밋당 귀속, decision-27). 귀속 기록 `/.gitformat-token-attributed`(GF-129) | — | | `Hooks-Commit` | `trailer_hooks_commit()` | 훅이 들어 있는 git-trail 클론의 `rev-parse --short HEAD` | — | | `Signed-off-by` | `trailer_signed_off_by()` | 커밋 시점의 커미터(`%cn <%ce>`) | — (decision-25로 유지) | @@ -61,8 +61,8 @@ git-trail에서는 커밋마다 붙는 출처 트레일러가 provenance다. **빈 곳** - Claude Code 외 도구의 `AI-Model`은 자가신고다(decision-15). 사용량 채널이 없어 `Tokens-Used`는 `unavailable (no-usage-channel)`. - 트랜스크립트 경로는 Claude Code의 문서화되지 않은 규칙에 기댄다(`claude_transcript_path()` 주석). -- 서브에이전트의 토큰은 부모 트랜스크립트에 없어 집계되지 않는다. -- **설계 의도는 커밋당 토큰량**(이 커밋에 스테이징된 파일을 실제로 건드린 응답의 토큰)이다. 지금 구현은 직전 커밋 이후 구간 전체(델타)를 세고 있어 의도와 다르다. GF-129에서 귀속 방식으로 바꾸고, 델타는 기록하지 않는다. +- `Tokens-Used`의 귀속 판정은 경로 문자열 대조다. 경로를 쓰지 않고 파일을 바꾸는 명령(글롭, 변수, `cd` 후 파일명만)은 놓치고, 경로를 담기만 한 명령(`git add`)은 귀속한다. 어느 커밋 파일도 건드리지 않은 탐색·대화 비용은 기록되지 않는다(decision-27, doc-16 "한계"). +- 서브에이전트 트랜스크립트 레이아웃(`<세션 ID>/subagents/*.jsonl`)도 문서화되지 않은 내부 구현이라, 바뀌면 서브에이전트 몫이 조용히 빠진다. - commit-msg의 게이트와 검사(`enforce_task_id_branch()`, `enforce_ai_model_gate()`, `validate_*`)는 `--no-verify`로 건너뛸 수 있고, 지금은 그 흔적도 남지 않는다(`Verify-Bypassed`가 사실상 도달 불가). 검증을 prepare-commit-msg로 옮기는 GF-127에서 닫힌다. ## attestation — 증명 diff --git "a/backlog/docs/doc-3 - AI-\352\267\200\354\206\215-\355\212\270\353\240\210\354\235\274\353\237\254-\353\240\210\355\215\274\353\237\260\354\212\244.md" "b/backlog/docs/doc-3 - AI-\352\267\200\354\206\215-\355\212\270\353\240\210\354\235\274\353\237\254-\353\240\210\355\215\274\353\237\260\354\212\244.md" index 602c818..dec3a5d 100644 --- "a/backlog/docs/doc-3 - AI-\352\267\200\354\206\215-\355\212\270\353\240\210\354\235\274\353\237\254-\353\240\210\355\215\274\353\237\260\354\212\244.md" +++ "b/backlog/docs/doc-3 - AI-\352\267\200\354\206\215-\355\212\270\353\240\210\354\235\274\353\237\254-\353\240\210\355\215\274\353\237\260\354\212\244.md" @@ -3,7 +3,7 @@ id: doc-3 title: AI 귀속 트레일러 레퍼런스 type: guide created_date: '2026-09-19 05:27' -updated_date: '2026-09-24 18:26' +updated_date: '2026-10-03 07:30' --- ## 🤖 AI 귀속 footer @@ -16,8 +16,8 @@ AI 코딩 에이전트가 커밋했다면 아래 트레일러가 자동으로 | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | `AI-Tool`, `AI-Tool-Version` | `AI_AGENT` 환경변수(Claude Code 프로세스가 하위 프로세스에 주입) | 강제 — LLM이 스스로 만든 값이 아님 | | `AI-Model` | **Claude Code**: 세션 트랜스크립트(`~/.claude/projects//.jsonl`)의 `message.model` — Anthropic API 응답을 그대로 기록한 값. **그 외 도구**: `git config gitformat.aiModel`(commit-msg가 존재/화이트리스트를 강제) | Claude Code는 서버 발급 사실 / 그 외는 존재+형식만 강제, 진실성은 검증 불가 | -| `Tokens-Used` | AI-Tool이 감지된 모든 커밋에 붙습니다. **Claude Code**: 같은 세션 트랜스크립트에서 `message.usage`(input/output/cache 토큰) 합계 — 이전 커밋 이후 새로 추가된 구간만(누적 아님, 아래 참고). **그 외 도구/실패 시**: `unavailable (사유)` (아래 참고) | 서버 발급 사실(자가신고 아님, Claude Code 한정) / 그 외는 실측 채널 없음 | -| `Tool-Calls` | 같은 구간에서 assistant 메시지의 `tool_use` 콘텐츠 블록 개수 — 출처/한계는 `Tokens-Used`와 동일 | 서버 발급 사실(자가신고 아님, Claude Code 한정) / 그 외는 실측 채널 없음 | +| `Tokens-Used` | AI-Tool이 감지된 모든 커밋에 붙습니다. **Claude Code**: `in=<입력> out=<출력>` — 세션·서브에이전트 트랜스크립트에서 **이 커밋이 바꾼 파일을 건드린 응답**의 `message.usage` 합(커밋당 귀속, 델타 아님, decision-27). `in`은 input+cache_creation+cache_read, `out`은 output. 귀속된 응답이 없으면 `in=0 out=0 (no-attributed-turn)`. **그 외 도구/실패 시**: `unavailable (사유)` (아래 참고) | 서버 발급 사실(자가신고 아님, Claude Code 한정) / 귀속 판정은 경로 문자열 대조라 근사 / 그 외는 실측 채널 없음 | +| `Tool-Calls` | 귀속된 응답의 `tool_use` 블록 중 이 커밋이 바꾼 파일을 실제로 건드린 것의 개수. 귀속된 응답이 없으면 `0 (no-attributed-turn)` — 출처/한계는 `Tokens-Used`와 동일 | 서버 발급 사실(자가신고 아님, Claude Code 한정) / 그 외는 실측 채널 없음 | | `Co-Authored-By` | `AI-Tool`이 `claude-code`일 때만 자동 삽입 | 자동 | | `Hooks-Commit` | 이 git-format 클론 자체의 `git rev-parse --short HEAD` | 완전 자동, 모든 커밋에 적용(AI 여부 무관) | | `Signed-off-by` | 커미터 정보(`git log -1 --format='%cn <%ce>'`) | 완전 자동, 모든 커밋에 적용(AI 여부 무관, `git commit -s`와 동일 방식, decision-10) | @@ -26,14 +26,19 @@ AI 코딩 에이전트가 커밋했다면 아래 트레일러가 자동으로 파일 경로를 찾는 데만 내부적으로 쓰이고, 값 자체가 커밋 footer에 남지는 않습니다 — 세션 식별자를 공개 저장소 히스토리에 영구히 남기지 않기 위함입니다. -`Tokens-Used`/`Tool-Calls`는 한 세션에서 커밋이 여러 번 나올 수 있다는 점을 감안해 -**세션 누적치가 아니라 이전 커밋 이후의 델타**만 집계합니다. `<대상 저장소>/.git/.gitformat-token-cursor` -파일에 "이미 처리한 트랜스크립트 줄 수"를 기억해뒀다가 다음 커밋에서 그 이후 줄만 -다시 읽습니다. +`Tokens-Used`/`Tool-Calls`는 **이 커밋에 든 양**입니다(decision-27). 세션 트랜스크립트와 +서브에이전트 트랜스크립트(`<세션 ID>/subagents/*.jsonl`) 전체에서, 도구 호출이 이 커밋이 바꾼 +파일(`git diff-tree … HEAD`)을 건드린 응답만 골라 더합니다. `file_path`/`notebook_path`는 +저장소 상대경로로 정규화해 완전 일치, Bash는 명령에 저장소 상대경로가 들어 있는지로 +판정합니다(파일명만으로는 매칭하지 않음). 응답 하나는 한 커밋에만 들어가도록, 귀속한 응답 +키를 `<대상 저장소>/.git/.gitformat-token-attributed`에 기억합니다(세션이 바뀌면 새로 시작). +어느 커밋 파일도 건드리지 않은 응답(탐색·대화)은 어디에도 들어가지 않습니다. 상세는 doc-16. 측정에 실패하면 `unavailable (사유 슬러그)`로 명시 기록합니다(사유: `no-session-id`/`transcript-not-found`/`transcript-unreadable`/ -`transcript-parse-failed`/`no-usage-channel`). 커서는 실측 성공 시에만 갱신하고, 실제로 -읽어서 합산한 값이 0이면 `0`으로 그대로 기록합니다(생략하지 않음). **주의**: 이 값은 -"이 커밋에 정확히 필요했던 토큰"이 아니라 "직전 커밋 이후 세션에서 소비된 토큰"의 -근사치입니다 — 측정 방법론 자체가 아직 실험적이며 계속 개선할 예정입니다. +`transcript-parse-failed`/`no-usage-channel`). 기록 파일은 실측 성공 시에만 갱신합니다. +귀속된 응답의 usage가 정말 0이면 `in=0 out=0`으로, 귀속된 응답이 아예 없으면 +`in=0 out=0 (no-attributed-turn)`으로 구별해 남깁니다(생략하지 않음). + +GF-129 이전 커밋의 `Tokens-Used`는 단일 숫자로, "직전 커밋 이후 세션에서 소비된 토큰"(델타, +`.gitformat-token-cursor`)이었습니다. 형식이 달라 구별되며 고치지 않습니다. From 51acc684713b2e270338da62f9aa6f6e077539fa Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:31:46 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[docs][backlog]=20GF-129=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=85=B8=ED=8A=B8=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=0 out=0 (no-attributed-turn) Tool-Calls: 0 (no-attributed-turn) Hooks-Commit: 5d72d5f Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- ...4-\354\236\254\354\236\221\354\204\261.md" | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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 7b4f60c..8d071a1 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" @@ -5,7 +5,7 @@ status: In Progress assignee: - '@claude' created_date: '2026-09-25 19:33' -updated_date: '2026-10-03 07:20' +updated_date: '2026-10-03 07:31' labels: - hooks - trailers @@ -73,6 +73,23 @@ type: bug 6. 아티팩트 '훅 해부' 갱신·재발행 +## Implementation Notes + + +[구현 2026-10-03, 서브에이전트] hooks/post-commit에 커밋당 귀속 구현(decision-27). +- 후보: 세션 트랜스크립트 + <세션 디렉터리>/<세션 ID>/subagents/*.jsonl(이름순, 없으면 무시). (requestId, message.id)로 응답 묶기 — usage는 첫 줄 한 번, tool_use는 모든 줄. id 없는 줄은 각자 응답, 키 '<트랜스크립트 디렉터리 기준 파일 경로>:<줄 번호>'. 공백 든 id도 id 없음으로 취급(기록 파일 줄 경계 보호). +- 대상: git diff-tree --root --no-commit-id --name-only -r -z HEAD. +- file_path/notebook_path: 최상위 기준 상대경로, 디렉터리 부분만 realpath(파일 자체 심볼릭 링크는 링크 이름 유지), 저장소 밖은 탈락, 완전 일치. Read도 포함. +- Bash: 경계로 구분된 저장소 상대경로. 왼쪽 경계 = 시작/공백/따옴표/백틱/셸 구두점(= ( < > | ; & , :), 하위 경로 대상은 '/'도 허용(절대경로·../ 표기). 오른쪽 = 끝 또는 [A-Za-z0-9_.-/]가 아닌 글자. 최상위 대상은 '/' 왼쪽 경계 불허(sub/a.txt 오매칭 방지), 대신 ./a.txt와 최상위 절대경로(논리·물리) 허용. 그 밖의 도구는 귀속 안 함. +- 기록: .git/.gitformat-token-attributed(첫 줄 세션 ID, 이후 응답 키). 세션 불일치/없음/손상 → 새로 시작. 성공 시에만 기록(귀속 0개여도). 예전 .gitformat-token-cursor는 읽지 않고 측정 성공 시 삭제. +- 형식: Tokens-Used: in= out=, Tool-Calls: <대상 경로를 건드린 블록 수>. 귀속 없음 → in=0 out=0 (no-attributed-turn) / 0 (no-attributed-turn). +- 실패: 본/서브에이전트 어느 파일이든 깨진 줄 → transcript-parse-failed, 기록 파일 그대로. 세션 전체를 매번 읽으므로 한 번 깨지면 그 세션 이후 커밋은 계속 이 사유. +- 테스트: tests/test_token_usage_measurement.py 20건으로 재작성(델타/커서 테스트는 새 의미로), test_trailer_ai_attribution의 GF-98 케이스 기대값 갱신, isolated_repo.write_subagent_transcript 추가. 전체 106건 통과, ruff 통과. +- 실커밋 확인: 526ea53(구현 커밋)이 Tokens-Used: in=3523137 out=12193 / Tool-Calls: 31. 독립 스크립트 합산과 대조 — 차이는 커밋 뒤에 생긴 대조 명령 응답 1건뿐. +- 문서: doc-16/doc-3/doc-21/doc-1(backlog CLI), README 갱신. 아티팩트 '훅 해부'는 미갱신(AC#14 잔여). +- 알려진 한계(범위 밖): 사용자 --amend 시 원래 커밋이 귀속한 응답은 다시 세지 않아 Tokens-Used 트레일러가 하나 더 붙을 수 있음(기존 trailer_exists 동작). 최상위 파일은 grep 등에 파일명이 경계로 나오기만 해도 귀속됨. + + ## Comments From 5a5ddbfb2750fbf0056331532366578335450105 Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:31:54 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[docs][backlog]=20GF-129=20=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=0 out=0 (no-attributed-turn) Tool-Calls: 0 (no-attributed-turn) Hooks-Commit: f922737 Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- ...234\274\353\241\234-\354\236\254\354\236\221\354\204\261.md" | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8d071a1..2afc2e4 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" @@ -84,7 +84,7 @@ type: bug - 기록: .git/.gitformat-token-attributed(첫 줄 세션 ID, 이후 응답 키). 세션 불일치/없음/손상 → 새로 시작. 성공 시에만 기록(귀속 0개여도). 예전 .gitformat-token-cursor는 읽지 않고 측정 성공 시 삭제. - 형식: Tokens-Used: in= out=, Tool-Calls: <대상 경로를 건드린 블록 수>. 귀속 없음 → in=0 out=0 (no-attributed-turn) / 0 (no-attributed-turn). - 실패: 본/서브에이전트 어느 파일이든 깨진 줄 → transcript-parse-failed, 기록 파일 그대로. 세션 전체를 매번 읽으므로 한 번 깨지면 그 세션 이후 커밋은 계속 이 사유. -- 테스트: tests/test_token_usage_measurement.py 20건으로 재작성(델타/커서 테스트는 새 의미로), test_trailer_ai_attribution의 GF-98 케이스 기대값 갱신, isolated_repo.write_subagent_transcript 추가. 전체 106건 통과, ruff 통과. +- 테스트: tests/test_token_usage_measurement.py 19건으로 재작성(델타/커서 테스트는 새 의미로), test_trailer_ai_attribution의 GF-98 케이스 기대값 갱신, isolated_repo.write_subagent_transcript 추가. 전체 106건 통과, ruff 통과. - 실커밋 확인: 526ea53(구현 커밋)이 Tokens-Used: in=3523137 out=12193 / Tool-Calls: 31. 독립 스크립트 합산과 대조 — 차이는 커밋 뒤에 생긴 대조 명령 응답 1건뿐. - 문서: doc-16/doc-3/doc-21/doc-1(backlog CLI), README 갱신. 아티팩트 '훅 해부'는 미갱신(AC#14 잔여). - 알려진 한계(범위 밖): 사용자 --amend 시 원래 커밋이 귀속한 응답은 다시 세지 않아 Tokens-Used 트레일러가 하나 더 붙을 수 있음(기존 trailer_exists 동작). 최상위 파일은 grep 등에 파일명이 경계로 나오기만 해도 귀속됨. From fd46eee8671b41c4751f3e45f96a724c976edd22 Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:34:16 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[docs][readme]=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=98=88=EC=8B=9C=EB=A5=BC=20=EC=8B=A4=EC=B8=A1=20=EA=B7=80?= =?UTF-8?q?=EC=86=8D=EA=B0=92=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=1353682 out=2267 Tool-Calls: 4 Hooks-Commit: 5bf026c Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- README.md | 72 +++++++++++++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 1e92570..ca76772 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ AI-Tool: claude-code AI-Tool-Version: 2.1.267 Co-Authored-By: Claude AI-Model: claude-opus-5 -Tokens-Used: in=1952818 out=8402 -Tool-Calls: 2 +Tokens-Used: in=5067994 out=15794 +Tool-Calls: 31 Hooks-Commit: c828491 Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> ``` @@ -49,27 +49,27 @@ Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> **제목과 본문** — 사람(또는 에이전트)이 직접 씁니다. `commit-msg`가 검증합니다. -| 필드 | 무엇인가 · 왜 필요한가 | 규칙 | -| --- | --- | --- | -| `type` | 변경의 종류. `git log`를 종류별로 걸러 읽을 수 있게 합니다 | `feat fix docs style refactor perf test build ci chore revert` 중 하나 | -| `subsystem` | 영향 범위. 어느 부분이 바뀌었는지 제목만 보고 알게 합니다 | 선택. `[a-zA-Z0-9_.-]` | -| 설명 | 무엇을 했는지 | **50자 이내**(유니코드 코드포인트), 명령형 현재형 | -| 본문 | **왜** 바꿨는지. 어떻게는 diff가 이미 보여줍니다 | 선택. 한 줄 **72자 이내**, 제목과 빈 줄로 분리 | +| 필드 | 무엇인가 · 왜 필요한가 | 규칙 | +| ----------- | ---------------------------------------------------------- | ---------------------------------------------------------------------- | +| `type` | 변경의 종류. `git log`를 종류별로 걸러 읽을 수 있게 합니다 | `feat fix docs style refactor perf test build ci chore revert` 중 하나 | +| `subsystem` | 영향 범위. 어느 부분이 바뀌었는지 제목만 보고 알게 합니다 | 선택. `[a-zA-Z0-9_.-]` | +| 설명 | 무엇을 했는지 | **50자 이내**(유니코드 코드포인트), 명령형 현재형 | +| 본문 | **왜** 바꿨는지. 어떻게는 diff가 이미 보여줍니다 | 선택. 한 줄 **72자 이내**, 제목과 빈 줄로 분리 | **트레일러** — 훅이 자동으로 붙입니다. 사람이 타이핑하지 않습니다. -| 트레일러 | 무엇인가 · 왜 필요한가 | 만드는 훅 · 값의 출처 | 신뢰 수준 | -| --- | --- | --- | --- | -| `Task-Id` | 이 커밋이 어느 작업의 일부인지. 커밋과 태스크를 잇습니다 | `post-commit` · 브랜치명의 `-<번호>`(decision-4) | 자동 | -| `AI-Tool`
`AI-Tool-Version` | 어떤 도구가 커밋을 만들었는지 | `post-commit` · `AI_AGENT` 환경변수(Claude Code가 하위 프로세스에 주입) | 강제 — LLM이 스스로 만든 값이 아님 | -| `AI-Model` | 어떤 모델이 썼는지. 모델별 작업 품질을 나중에 비교할 수 있게 합니다 | `post-commit` · Claude Code는 세션 트랜스크립트의 `message.model`, 그 외는 `gitformat.aiModel` 설정값 | Claude Code는 서버 발급 사실 / 그 외는 자가신고 | -| `Tokens-Used` | 이 커밋에 든 토큰(`in=<입력> out=<출력>`). 작업 비용을 이력에서 읽게 합니다 | `post-commit` · 이 커밋이 바꾼 파일을 건드린 응답의 토큰 합(귀속), 델타 아님(decision-27) | 서버 발급 사실(Claude Code 한정), **측정 방법론은 실험 단계** | -| `Tool-Calls` | 이 커밋의 파일을 건드린 도구 호출 수 | `post-commit` · 귀속된 응답에서 대상 파일을 건드린 `tool_use` 블록 개수 | 위와 동일 | -| `Co-Authored-By` | 공동저자 귀속 | `post-commit` · `AI-Tool`이 `claude-code`일 때만 | 자동 | -| `Hooks-Commit` | 이 커밋을 검사한 git-trail 자체의 버전. 훅에 버그가 있었을 때 어느 커밋들이 그 훅을 거쳤는지 역추적합니다 | `post-commit` · 훅 클론의 `rev-parse --short HEAD` | 완전 자동, 모든 커밋 | -| `Signed-off-by` | 커미터 정보(DCO 관례) | `post-commit` · `git log -1 --format='%cn <%ce>'` | 완전 자동, 모든 커밋 | -| `Verify-Bypassed` | `--no-verify`로 검사를 건너뛴 사실. 우회를 막지는 못하니 대신 기록합니다(decision-3) | `post-commit` · `prepare-commit-msg`가 남긴 검증 마커의 **부재** | 자동 | -| `Fixes` | 이 버그를 만든 커밋 | **사람이 씀**(원인 커밋을 아는 경우만) · `commit-msg`가 해시 실재를 검증 | 검증됨 | +| 트레일러 | 무엇인가 · 왜 필요한가 | 만드는 훅 · 값의 출처 | 신뢰 수준 | +| ------------------------------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `Task-Id` | 이 커밋이 어느 작업의 일부인지. 커밋과 태스크를 잇습니다 | `post-commit` · 브랜치명의 `-<번호>`(decision-4) | 자동 | +| `AI-Tool`
`AI-Tool-Version` | 어떤 도구가 커밋을 만들었는지 | `post-commit` · `AI_AGENT` 환경변수(Claude Code가 하위 프로세스에 주입) | 강제 — LLM이 스스로 만든 값이 아님 | +| `AI-Model` | 어떤 모델이 썼는지. 모델별 작업 품질을 나중에 비교할 수 있게 합니다 | `post-commit` · Claude Code는 세션 트랜스크립트의 `message.model`, 그 외는 `gitformat.aiModel` 설정값 | Claude Code는 서버 발급 사실 / 그 외는 자가신고 | +| `Tokens-Used` | 이 커밋에 든 토큰(`in=<입력> out=<출력>`). 작업 비용을 이력에서 읽게 합니다 | `post-commit` · 이 커밋이 바꾼 파일을 건드린 응답의 토큰 합(귀속), 델타 아님(decision-27) | 서버 발급 사실(Claude Code 한정), **측정 방법론은 실험 단계** | +| `Tool-Calls` | 이 커밋의 파일을 건드린 도구 호출 수 | `post-commit` · 귀속된 응답에서 대상 파일을 건드린 `tool_use` 블록 개수 | 위와 동일 | +| `Co-Authored-By` | 공동저자 귀속 | `post-commit` · `AI-Tool`이 `claude-code`일 때만 | 자동 | +| `Hooks-Commit` | 이 커밋을 검사한 git-trail 자체의 버전. 훅에 버그가 있었을 때 어느 커밋들이 그 훅을 거쳤는지 역추적합니다 | `post-commit` · 훅 클론의 `rev-parse --short HEAD` | 완전 자동, 모든 커밋 | +| `Signed-off-by` | 커미터 정보(DCO 관례) | `post-commit` · `git log -1 --format='%cn <%ce>'` | 완전 자동, 모든 커밋 | +| `Verify-Bypassed` | `--no-verify`로 검사를 건너뛴 사실. 우회를 막지는 못하니 대신 기록합니다(decision-3) | `post-commit` · `prepare-commit-msg`가 남긴 검증 마커의 **부재** | 자동 | +| `Fixes` | 이 버그를 만든 커밋 | **사람이 씀**(원인 커밋을 아는 경우만) · `commit-msg`가 해시 실재를 검증 | 검증됨 | `Tokens-Used`/`Tool-Calls`는 측정에 실패하면 사유와 함께 `unavailable (사유)`로 남습니다 — `no-session-id`, `transcript-not-found`, `transcript-unreadable`, `transcript-parse-failed`, @@ -82,13 +82,13 @@ Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> > git-trail의 훅은 지금 커밋 단계만 다룹니다. push된 이력의 보호는 원격 설정이 맡습니다 > ([다루는 범위](#-다루는-범위)). -| 순서 | 훅 | 커밋 객체가 있는가 | `--no-verify`로 건너뛰나 | git-trail이 하는 일 | -| --- | --- | --- | --- | --- | -| 1 | [`prepare-commit-msg`](hooks/prepare-commit-msg) | 아직 없음 | **건너뛸 수 없음** | 재생·병합 커밋 면제, 에디터 경로 거부, 검증 마커 기록 | -| 2 | (에디터) | 아직 없음 | — | 사람이 메시지를 씁니다. 훅보다 **뒤**라서 1번은 최종 메시지를 볼 수 없습니다 | -| 3 | [`commit-msg`](hooks/commit-msg) | 아직 없음 | 건너뜀 | 제목 형식·길이, 빈 줄, `Fixes` 해시, 브랜치 `Task-Id`, `AI-Model` 화이트리스트 검증 | -| 4 | (커밋 생성) | **생성됨** | — | git이 커밋 객체를 만듭니다 | -| 5 | [`post-commit`](hooks/post-commit) | 있음 | 건너뛸 수 없음 | 트레일러를 `git commit --amend`로 삽입. exit code가 커밋 결과에 영향을 주지 못합니다 | +| 순서 | 훅 | 커밋 객체가 있는가 | `--no-verify`로 건너뛰나 | git-trail이 하는 일 | +| ---- | ------------------------------------------------ | ------------------ | ------------------------ | ------------------------------------------------------------------------------------ | +| 1 | [`prepare-commit-msg`](hooks/prepare-commit-msg) | 아직 없음 | **건너뛸 수 없음** | 재생·병합 커밋 면제, 에디터 경로 거부, 검증 마커 기록 | +| 2 | (에디터) | 아직 없음 | — | 사람이 메시지를 씁니다. 훅보다 **뒤**라서 1번은 최종 메시지를 볼 수 없습니다 | +| 3 | [`commit-msg`](hooks/commit-msg) | 아직 없음 | 건너뜀 | 제목 형식·길이, 빈 줄, `Fixes` 해시, 브랜치 `Task-Id`, `AI-Model` 화이트리스트 검증 | +| 4 | (커밋 생성) | **생성됨** | — | git이 커밋 객체를 만듭니다 | +| 5 | [`post-commit`](hooks/post-commit) | 있음 | 건너뛸 수 없음 | 트레일러를 `git commit --amend`로 삽입. exit code가 커밋 결과에 영향을 주지 못합니다 | 여기서 두 가지가 나옵니다. **에디터가 1번보다 뒤에 열리므로** 최종 메시지 검증은 `git commit -m`(또는 `-F`)만 가능하고, 그래서 에디터 경로는 거부합니다. @@ -99,11 +99,11 @@ Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> git 이력을 작업 로그로 만드는 데 필요한 세 가지입니다(decision-25). -| 기둥 | 하는 일 | 지금 있는 것 | 계획 | -| --- | --- | --- | --- | -| **형식** | 사람이 쓰는 제목·본문을 정해진 양식으로 | 제목·본문 검증, 에디터 경로 거부, 커밋 단위 규칙(decision-22, 강제 없음) | 검증을 `prepare-commit-msg`로 통합(GF-127) | -| **출처 기록** | 누가·어떤 도구와 모델로·얼마를 들여 만들었는지 자동으로 남김 | 위 트레일러 전체, `Task-Id` 브랜치 강제, `AI-Model` 게이트 | 측정 재작성(GF-129), 트레일러 집합 재정의(decision-19, `Signed-off-by`는 유지 — decision-25) | -| **이력 불변** | 한 번 남긴 기록은 다시 쓰지 않음(append-only, decision-24) | 이 저장소의 GitHub 설정: 머지 커밋만 허용, main의 force push·삭제 금지(doc-20). 컨슈머는 doc-20을 참고해 직접 설정 | 로컬 훅 강제(DRAFT-18 append-only 훅 강제), CI 트레일러 검사(DRAFT-19) | +| 기둥 | 하는 일 | 지금 있는 것 | 계획 | +| ------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | +| **형식** | 사람이 쓰는 제목·본문을 정해진 양식으로 | 제목·본문 검증, 에디터 경로 거부, 커밋 단위 규칙(decision-22, 강제 없음) | 검증을 `prepare-commit-msg`로 통합(GF-127) | +| **출처 기록** | 누가·어떤 도구와 모델로·얼마를 들여 만들었는지 자동으로 남김 | 위 트레일러 전체, `Task-Id` 브랜치 강제, `AI-Model` 게이트 | 측정 재작성(GF-129), 트레일러 집합 재정의(decision-19, `Signed-off-by`는 유지 — decision-25) | +| **이력 불변** | 한 번 남긴 기록은 다시 쓰지 않음(append-only, decision-24) | 이 저장소의 GitHub 설정: 머지 커밋만 허용, main의 force push·삭제 금지(doc-20). 컨슈머는 doc-20을 참고해 직접 설정 | 로컬 훅 강제(DRAFT-18 append-only 훅 강제), CI 트레일러 검사(DRAFT-19) | 용어로 말하면 trail(감사 추적)이 세 기둥 전체이고, provenance(출처 기록)가 두 번째 기둥입니다. attestation(서명된 증명)은 아직 없고, audit(사후 검사)은 커밋 시점 검사까지만 있습니다 — @@ -136,10 +136,10 @@ npm/ruff/clang-format 같은 도구를 전제하게 만듭니다. 언어 검사 `python3`를 못 찾을 때의 증상은 훅마다 다릅니다. -| 훅 | python3가 PATH에 없을 때 | -| --- | --- | -| `prepare-commit-msg`, `commit-msg` | 훅이 실패하고 git이 커밋을 막습니다 — 에러가 바로 보이는 안전한 실패입니다. | -| `post-commit` | 커밋이 이미 만들어진 뒤라 git이 훅의 실패를 반영하지 않습니다 — 커밋은 성공한 것처럼 보이지만 `Task-Id`/`AI-Model`/`Signed-off-by` 같은 트레일러가 조용히 누락됩니다. | +| 훅 | python3가 PATH에 없을 때 | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prepare-commit-msg`, `commit-msg` | 훅이 실패하고 git이 커밋을 막습니다 — 에러가 바로 보이는 안전한 실패입니다. | +| `post-commit` | 커밋이 이미 만들어진 뒤라 git이 훅의 실패를 반영하지 않습니다 — 커밋은 성공한 것처럼 보이지만 `Task-Id`/`AI-Model`/`Signed-off-by` 같은 트레일러가 조용히 누락됩니다. | GUI git 클라이언트(SourceTree, GitHub Desktop, IDE 내장 git 패널)는 셸 프로파일(`.zshrc` 등)을 거치지 않고 OS 최소 PATH만 물려받는 경우가 흔합니다. 최신 From 17b7a03e1989c7b208c7d371b60eccd61c5169df Mon Sep 17 00:00:00 2001 From: cpu-once <231006716+cpu-once@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:35:33 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[docs][backlog]=20GF-129=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-Id: GF-129 AI-Tool: claude-code AI-Tool-Version: 2.1.285 Co-Authored-By: Claude AI-Model: claude-opus-5-5 Tokens-Used: in=0 out=0 (no-attributed-turn) Tool-Calls: 0 (no-attributed-turn) Hooks-Commit: 6a7bb40 Signed-off-by: cpu-once <231006716+cpu-once@users.noreply.github.com> --- ...4-\354\236\254\354\236\221\354\204\261.md" | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) 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 2afc2e4..43f2dae 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" @@ -1,11 +1,11 @@ --- id: GF-129 title: 토큰·툴콜 측정을 응답 단위 중복 제거와 커밋 귀속으로 재작성 -status: In Progress +status: Done assignee: - '@claude' created_date: '2026-09-25 19:33' -updated_date: '2026-10-03 07:31' +updated_date: '2026-10-03 07:35' labels: - hooks - trailers @@ -39,27 +39,27 @@ type: bug ## Acceptance Criteria -- [ ] #1 assistant 줄을 (requestId, message.id)로 그룹핑해 usage를 응답당 한 번만 계상한다 -- [ ] #2 한 응답의 tool_use 블록은 여러 줄에 흩어져 있으므로 모든 줄에서 모아 귀속 판정에 쓴다 -- [ ] #3 file_path/notebook_path 필드를 가진 도구는 저장소 상대경로로 정규화해 완전 일치로 판정한다 -- [ ] #4 Bash 명령은 명령 문자열에 저장소 상대경로가 들어 있는지로 판정하며, 파일명이나 파일명 앞토큰으로는 매칭하지 않는다 -- [ ] #5 in은 input_tokens와 cache_creation_input_tokens, cache_read_input_tokens의 합이다 -- [ ] #6 Tool-Calls는 대상 경로를 실제로 건드린 tool_use 블록 개수다 -- [ ] #7 트랜스크립트를 읽을 수 없으면 사유 슬러그와 함께 unavailable로 남긴다 -- [ ] #8 귀속 대상 경로는 방금 만든 커밋이 바꾼 파일 목록이다 (post-commit 시점이라 git diff --cached는 비어 있으므로 git diff-tree --root --no-commit-id --name-only -r HEAD) -- [ ] #9 Tokens-Used를 in=<입력> out=<출력> 한 줄로 기록한다. 직전 커밋 이후 구간 전체(델타)는 어디에도 기록하지 않는다 -- [ ] #10 세션 트랜스크립트 전체를 훑되, 응답 하나는 한 커밋에만 귀속된다 — 이미 귀속된 응답 키를 git-dir의 기록 파일에 남겨 다시 세지 않는다(세션이 바뀌면 기록을 새로 시작) -- [ ] #11 서브에이전트 트랜스크립트(<세션>/subagents/*.jsonl)도 귀속 대상에 포함한다 -- [ ] #12 귀속된 응답이 하나도 없으면 in=0 out=0에 no-attributed-turn 사유를 붙여 진짜 측정값과 구별한다 -- [ ] #13 decision-27로 'Tokens-Used는 커밋당 토큰량, 델타 기록 안 함'을 기록하고 decision-19의 delta 조항을 대체한다고 명시한다 -- [ ] #14 README, doc-3, doc-16, doc-21과 아티팩트 '훅 해부'의 Tokens-Used 서술을 커밋당 토큰량으로 바꾼다 +- [x] #1 assistant 줄을 (requestId, message.id)로 그룹핑해 usage를 응답당 한 번만 계상한다 +- [x] #2 한 응답의 tool_use 블록은 여러 줄에 흩어져 있으므로 모든 줄에서 모아 귀속 판정에 쓴다 +- [x] #3 file_path/notebook_path 필드를 가진 도구는 저장소 상대경로로 정규화해 완전 일치로 판정한다 +- [x] #4 Bash 명령은 명령 문자열에 저장소 상대경로가 들어 있는지로 판정하며, 파일명이나 파일명 앞토큰으로는 매칭하지 않는다 +- [x] #5 in은 input_tokens와 cache_creation_input_tokens, cache_read_input_tokens의 합이다 +- [x] #6 Tool-Calls는 대상 경로를 실제로 건드린 tool_use 블록 개수다 +- [x] #7 트랜스크립트를 읽을 수 없으면 사유 슬러그와 함께 unavailable로 남긴다 +- [x] #8 귀속 대상 경로는 방금 만든 커밋이 바꾼 파일 목록이다 (post-commit 시점이라 git diff --cached는 비어 있으므로 git diff-tree --root --no-commit-id --name-only -r HEAD) +- [x] #9 Tokens-Used를 in=<입력> out=<출력> 한 줄로 기록한다. 직전 커밋 이후 구간 전체(델타)는 어디에도 기록하지 않는다 +- [x] #10 세션 트랜스크립트 전체를 훑되, 응답 하나는 한 커밋에만 귀속된다 — 이미 귀속된 응답 키를 git-dir의 기록 파일에 남겨 다시 세지 않는다(세션이 바뀌면 기록을 새로 시작) +- [x] #11 서브에이전트 트랜스크립트(<세션>/subagents/*.jsonl)도 귀속 대상에 포함한다 +- [x] #12 귀속된 응답이 하나도 없으면 in=0 out=0에 no-attributed-turn 사유를 붙여 진짜 측정값과 구별한다 +- [x] #13 decision-27로 'Tokens-Used는 커밋당 토큰량, 델타 기록 안 함'을 기록하고 decision-19의 delta 조항을 대체한다고 명시한다 +- [x] #14 README, doc-3, doc-16, doc-21과 아티팩트 '훅 해부'의 Tokens-Used 서술을 커밋당 토큰량으로 바꾼다 ## Definition of Done -- [ ] #1 python3 -m unittest 스위트 전체 통과 (이관 전이면 bats tests/ 통과) -- [ ] #2 ruff check 통과 -- [ ] #3 이 저장소 자신의 커밋이 새 훅으로 정상 생성되는지 확인 +- [x] #1 python3 -m unittest 스위트 전체 통과 (이관 전이면 bats tests/ 통과) +- [x] #2 ruff check 통과 +- [x] #3 이 저장소 자신의 커밋이 새 훅으로 정상 생성되는지 확인 ## Implementation Plan @@ -88,6 +88,8 @@ type: bug - 실커밋 확인: 526ea53(구현 커밋)이 Tokens-Used: in=3523137 out=12193 / Tool-Calls: 31. 독립 스크립트 합산과 대조 — 차이는 커밋 뒤에 생긴 대조 명령 응답 1건뿐. - 문서: doc-16/doc-3/doc-21/doc-1(backlog CLI), README 갱신. 아티팩트 '훅 해부'는 미갱신(AC#14 잔여). - 알려진 한계(범위 밖): 사용자 --amend 시 원래 커밋이 귀속한 응답은 다시 세지 않아 Tokens-Used 트레일러가 하나 더 붙을 수 있음(기존 trailer_exists 동작). 최상위 파일은 grep 등에 파일명이 경계로 나오기만 해도 귀속됨. + +메인 검증(2026-10-03): post-commit 코드 리뷰(group_responses·commit_target_paths·AttributionMatcher·기록 파일·trailer_tokens_used), unittest 106개·ruff 통과. 실제 커밋 fd46eee(README 예시 갱신)의 Tokens-Used: in=1353682 out=2267 / Tool-Calls: 4를 별도 스크립트(세션+서브에이전트 트랜스크립트, 이전 기록 스냅샷 제외, README.md를 건드린 응답만)로 다시 계산해 정확히 일치, 귀속 응답 3개가 기록 파일에 새로 추가된 키와 일치. 예전 .gitformat-token-cursor는 지워지고 .gitformat-token-attributed가 생김. 문서: README·doc-1·doc-3·doc-16·doc-21 반영, doc-16은 델타 커서를 '과거: 폐기' 절로 보존. 아티팩트 '훅 해부'를 v2로 재발행 — 델타 절을 '토큰은 커밋당이다 — 델타가 아니다'로 다시 쓰고 귀속 다이어그램(DOT)으로 교체, 커밋 예시는 fd46eee 실측값. 참고: in에 cache_read가 포함돼 응답당 수십만 단위라 커밋당 값이 수백만이 나온다(AC#5 정의대로). 범위 밖 남은 것: 사용자 --amend 시 Tokens-Used 중복 가능, 최상위 파일명은 명령에 이름만 나와도 귀속. ## Comments @@ -105,3 +107,9 @@ created: 2026-10-03 07:20 2026-10-03 유저 지시로 범위 변경: (1) 델타를 측정·기록하지 않는다 — 설계 의도는 커밋당 토큰량이다. 예전 AC#6(in/out/delta 형식)·#8(delta=구간 전체)·#10(구간 0/no-attributed-turn)을 지우고 새 AC로 대체. (2) GF-128 의존성을 풀고 지금 post-commit에 구현한다(GF-139와 같은 방식). prepare-commit-msg로 옮기는 건 GF-128 때. (3) post-commit 시점이라 귀속 경로는 git diff --cached가 아니라 커밋의 파일 목록. (4) 'a 수정 → b 먼저 커밋 → a 커밋' 순서에서 놓치지 않도록 구간이 아니라 세션 전체를 훑고 응답 단위로 한 번만 귀속. (5) 서브에이전트 트랜스크립트 포함. --- + +## Final Summary + + +Tokens-Used를 델타가 아니라 커밋당 토큰량으로 바꿨다(decision-27, decision-19의 delta 조항 대체). post-commit이 세션·서브에이전트 트랜스크립트를 응답 단위로 묶고, 이 커밋이 바꾼 파일을 건드린 응답만 'in=<입력> out=<출력>'으로 합산하며, 응답 하나는 기록 파일로 한 커밋에만 귀속한다. 귀속이 없으면 no-attributed-turn. 테스트 19건으로 재작성(전체 106개)·ruff 통과, 실제 커밋 fd46eee 값이 독립 재계산과 일치. README·doc-1/3/16/21과 아티팩트 '훅 해부'(v2)를 갱신했다. +