Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hook captures

A transcript stamps each API call with the git branch the session was on, but not always: some calls carry no branch, and some carry a throwaway worktree-agent-… branch. In claim mode, branch-tally recovers those calls from git worktree list, which only works while the worktree still exists.

The branch-tally-capture plugin closes that gap. Its hooks write down which branch each session was on, as the session runs, in .tally/captures.jsonl. Reports read that file and use it for calls the transcript could not place.

The captures file

branch-tally capture appends one JSON object per line. A synthetic example:

{"v":1,"harness":"claude-code","kind":"session-start","session_id":"s1","branch":"feat/x","ts":"2026-11-11T10:00:00Z"}
{"v":1,"harness":"claude-code","kind":"subagent-stop","session_id":"s1","agent_id":"a1","branch":"feat/x","worktree_branch":"feat/x-part","ts":"2026-11-11T10:20:00Z"}
{"v":1,"harness":"claude-code","kind":"stop","session_id":"s1","branch":"feat/x","ts":"2026-11-11T10:30:00Z"}
FieldHolds
vThe captures schema version, currently 1.
harnessThe agent harness, claude-code.
kindThe hook moment: session-start, subagent-stop or stop.
session_idThe Claude Code session id.
agent_idThe subagent’s id. Present on subagent-stop lines.
branchThe branch checked out in the session’s working directory at that moment.
worktree_branchsubagent-stop only: the branch of the subagent’s worktree, when one is found.
tsWhen capture ran, in UTC.

worktree_branch is the branch of the worktree whose directory is named agent-<agent id> (compared case-insensitively). Failing that, it is the branch of the hook’s own checkout, when that checkout is a linked worktree. Otherwise it is left out, never guessed. It is stored for later use: reports do not read it today.

capture writes nothing for a SessionEnd or SubagentStart payload, and nothing when the working directory has a detached HEAD.

Where it lives

The file is always at .tally/captures.jsonl in the repository’s main checkout, even when the session runs in a git worktree. Every session’s captures land in one file, and that file outlives the worktrees. capture creates .tally/ and the file when they are missing.

Reports and record read it from the main checkout of the repository they run in (the --repo directory, . by default). --ledger does not move it.

What it never holds

No message content, no working directory and no path of any kind. The hook resolves its working directory to a branch name with git, then stores only the branch. That makes the file safe to commit, but it does hold real session ids, agent ids and branch names, and --redact does not change the file. --redact hides identifiers in report output only.

Committing it

branch-tally treats the captures file like the ledger: append-only JSON Lines, one event per line, written with LF line endings, and merged with git’s union driver. If you commit it, add this line to your repository’s .gitattributes, beside the ledger’s:

.tally/captures.jsonl merge=union

branch-tally does not add the line for you. Without it, two branches that both appended captures conflict when merged.

Reports read the file from disk, so captures help on the machine that wrote them whether or not you commit it. A capture only helps with calls in that machine’s transcripts.

Duplicates and bad lines

Two hooks racing on the same event can both write it, and a union merge can leave the same line twice. An exact duplicate line is harmless: it is collapsed when read, and not reported.

Lines that can’t be used are skipped and counted, never hidden. report and record say so on stderr, with counts only:

note: skipped 3 capture line(s) in .tally/captures.jsonl: 1 malformed, 1 unsupported version, 1 unknown kind

A missing file is not an error: reports run as if there were no captures.

How captures change a report

Captures are used in claim mode only, the default. --strict and --timeline ignore them, so those reports are the same with or without the file.

In claim mode a capture can place a call only when all of these hold:

  • the call was made by the session itself, not by a subagent. A subagent’s calls still go to the branch of the session that dispatched them (dispatcher roll-up);
  • the call’s transcript stamp is missing or a worktree-agent-… branch. A real branch stamp is never overridden;
  • the session’s captures bracket the call’s time and agree on one branch.

“Bracket” means: take the session’s last capture at or before the call and its first capture at or after it (every capture sharing that instant, on each side). Both sides are required. If every one of those names the same branch, the call gets that branch. A capture of a worktree-agent-… branch, a detached HEAD or an empty branch never counts as agreement.

flowchart LR
    A["10:00 session-start<br/>feat/x"] --> C(["10:10 call,<br/>no stamp"])
    C --> B["10:30 stop<br/>feat/x"]
    C -. "both sides agree" .-> R["counts toward feat/x"]

Had the stop at 10:30 named main, the captures would disagree and the call would get nothing from them.

A call with captures on one side only gets nothing from them either. Say a session starts on feat/x, you check out feat/y, and the agent makes a call. If the report runs before the next stop capture is written, or that capture failed, only the 10:00 feat/x capture comes before the call, and nothing comes after it to confirm the branch had not changed. So the captures don’t vouch for the call, even if every capture in the session names feat/x.

A bracket still can’t see everything between its two captures. If both sides name feat/x, the session could have switched to feat/y and back to feat/x in between, and the call would still count toward feat/x. The stop hook fires at the end of every turn, so the window is usually a single turn, and longer only when a capture failed. A call placed this way is a close inference, not a certainty, and that is why the report lists it as a heuristic (see What the report says).

When the captures don’t settle a call, nothing is guessed. The call goes through the same recovery as it would without captures (worktree recovery and origin recovery from git worktree list), and is as uncertain as it was before. When the captures do settle it, they win over that recovery, since they were written while the work happened.

What the report says

Calls placed by captures are a heuristic, and the report names it like the others. The Markdown footnote’s list of heuristics gains an entry:

captured hook data N call(s) / M tokens

The JSON output’s heuristics object gains captured, with calls and tokens. Both appear only when at least one call was placed by a capture, so a report that used no captures looks exactly as it did before.

record stores the basis with each ledger line, as captured, so a report from the ledger shows the same heuristic.