feat(token-events): auto-capture real token counts via PostToolUse hook

- Add PATCH /token-events/{id} endpoint to correct heuristic events
- Add `note` filter to GET /token-events/ list
- Add TokenEventPatch schema
- Add task_token_hook.py: PostToolUse hook that reads the Claude Code
  session transcript, computes per-task token delta, and replaces the
  heuristic token event with real measured counts (note="measured")
- Register hook in ~/.claude/settings.json on mcp__state-hub__update_task_status
  Covers both interactive sessions and ralph-workplan loops

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 22:38:45 +02:00
parent 29fca2a0c6
commit 21b6a410c2
3 changed files with 163 additions and 1 deletions

View File

@@ -10,7 +10,7 @@ from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.schemas.token_event import RepoTokenSummary, TokenEventCreate, TokenEventRead, TokenSummary
from api.schemas.token_event import RepoTokenSummary, TokenEventCreate, TokenEventPatch, TokenEventRead, TokenSummary
router = APIRouter(prefix="/token-events", tags=["token-events"])
@@ -166,6 +166,22 @@ async def get_tokens_by_repo(
]
@router.patch("/{event_id}", response_model=TokenEventRead)
async def patch_token_event(
event_id: uuid.UUID,
body: TokenEventPatch,
session: AsyncSession = Depends(get_session),
) -> TokenEvent:
event = await session.get(TokenEvent, event_id)
if event is None:
raise HTTPException(status_code=404, detail="Token event not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(event, field, value)
await session.commit()
await session.refresh(event)
return event
@router.get("/{event_id}", response_model=TokenEventRead)
async def get_token_event(
event_id: uuid.UUID,
@@ -186,6 +202,7 @@ async def list_token_events(
ref_id: str | None = None,
model: str | None = None,
agent: str | None = None,
note: str | None = None,
limit: int = Query(100, le=1000),
session: AsyncSession = Depends(get_session),
) -> list[TokenEvent]:
@@ -204,6 +221,8 @@ async def list_token_events(
q = q.where(TokenEvent.model == model)
if agent:
q = q.where(TokenEvent.agent == agent)
if note:
q = q.where(TokenEvent.note == note)
q = q.order_by(TokenEvent.created_at.desc()).limit(limit)
result = await session.execute(q)
return list(result.scalars().all())

View File

@@ -52,6 +52,14 @@ class TokenSummary(BaseModel):
by_agent: dict[str, int]
class TokenEventPatch(BaseModel):
tokens_in: int | None = None
tokens_out: int | None = None
note: str | None = None
model: str | None = None
agent: str | None = None
class RepoTokenSummary(BaseModel):
repo_id: uuid.UUID
repo_slug: str