Overview
What did this branch or pull request cost in AI-agent tokens?
branch-tally answers that question. It reads the transcripts an AI coding agent writes on your machine, assigns each recorded API call to the git branch the work was for, and prints a cost table for that branch. It can also write the table into the branch’s pull request. That includes work done by subagents running in git worktrees.
Claude Code is the agent harness supported today. branch-tally ships as a command-line tool for Linux, macOS and Windows, plus two optional Claude Code plugins whose hooks keep its numbers intact after transcripts are purged (see Integration).
Status: pre-alpha. The flags, output and ledger format may still change between releases. Follow the roadmap for what is planned.
The problem
Claude Code records every API call in local transcript files, with its token usage, the model and
the git branch the session was on. It does not tell you what a branch cost. A single session often
touches several branches. Subagents dispatched into worktrees record their calls under the session
that dispatched them, often stamped with a throwaway branch name. Transcripts are deleted after a
retention period (Claude Code’s cleanupPeriodDays, 30 days by default).
branch-tally does the bookkeeping: it finds the transcripts for a repository, decides which calls belong to the branch you ask about, adds up the tokens per model, and estimates the cost from a price table.
Honest numbers
A cost table is only useful if it says what it could not see. branch-tally holds itself to that:
- Uncertain usage is counted and reported, never dropped or guessed. When calls can’t be assigned to a branch with certainty, the report says how many, and the token total is labelled “at least”.
- Heuristics are named. When a call was assigned to the branch by a heuristic, such as the session that claimed it or the dispatcher a subagent rolled up to, the report’s footnote lists each heuristic with its call and token counts.
- Unpriced models make the total a floor. A model missing from the price table shows
—as its cost, is named in a warning on stderr, and the total reads “at least $X”. - Skipped input is announced. Malformed transcript lines, records without a request id and synthetic records are counted in a note on stderr.
- Costs are estimates. They are computed at list API rates from a dated price table, and the report says which table and which date. They are not the amounts you were billed.
Privacy
Transcripts hold your prompts, your code and sometimes secrets. branch-tally reads only the fields it needs: token usage, model, ids, timestamps, branch, working directory and the subagent dispatch link. Message content is never read into output. It does not appear in the report, in the ledger, in the hook captures file, in error messages or in anything sent to GitHub.
Reports do show session ids, agent and skill names, worktree names and timestamps. --redact hides
those identifiers in the output (see the CLI reference). The only network calls
branch-tally makes are prices update, which downloads a public price list, and report --pr,
which runs the GitHub CLI (gh) to edit your pull request.
How it works
Each stage consumes only the one before it.
flowchart LR
T["Claude Code transcripts<br/>~/.claude/projects/"] --> S[Source:<br/>read usage records]
S --> A[Attribution:<br/>assign each call a branch]
A --> Sel[Selection:<br/>keep the requested branch]
Sel -- "record" --> L[(".tally/ledger.jsonl")]
Sel --> P[Pricing:<br/>price table]
L -. "report --from-ledger" .-> P
P --> R[Render:<br/>Markdown or JSON]
R --> O[stdout]
R -- "report --pr" --> PR[Pull request body]
To zoom a diagram, use the + and − buttons in its corner, or hold Ctrl (Cmd on macOS) and turn the mouse wheel. Once it is zoomed in, drag to pan, and the reset button returns it to normal size.
- Source reads the transcript files for the repository and its worktrees, and keeps only usage records.
- Attribution assigns each call a branch, including subagent calls rolled up to the branch of the session that dispatched them. The three attribution modes are described in the CLI reference.
- Selection keeps the calls for the branch you asked about.
recordwrites those to the ledger, andreport --from-ledgerreads them back in place of the live transcripts. - Pricing applies the price table.
- Render prints the Markdown table or JSON, and
report --prsplices it into the pull request.
Where to go next
- Getting Started: install, run your first report and read it.
- Persisting the Ledger: keep usage after transcripts are purged, and combine machines.
- Integration: the optional Claude Code plugins that capture each session’s branch and fill the ledger for you.
- CLI reference and Config reference: every flag and key.
Getting Started
This page takes you from nothing to a cost table for a branch. You need a git repository where you have worked with Claude Code, on the same machine that ran the sessions.
1. Install
Linux (x86_64, aarch64) and macOS (Intel, Apple silicon):
curl -fsSL https://github.com/IAmSkweetis/branch-tally/releases/latest/download/install.sh | sh
Windows (PowerShell):
irm https://github.com/IAmSkweetis/branch-tally/releases/latest/download/install.ps1 | iex
Both put the binary in ~/.local/bin (%USERPROFILE%\.local\bin on Windows) and print the line
to add if that directory is not on your PATH. Check it worked:
branch-tally --version
Installation covers the options, verifying a download by hand and building from source.
2. Run your first report
From inside the repository, with the branch you want checked out:
branch-tally report
That reports on the current branch. To report on another branch, name it:
branch-tally report feat/my-change
branch-tally looks for transcripts under ~/.claude/projects/ (%USERPROFILE%\.claude\projects
on Windows) for this repository’s main checkout and each of its git worktrees. It only reads them.
If it prints No transcript records found for branch '…', the likely causes are:
- the sessions ran on another machine, or under a different working directory;
- the transcripts are older than Claude Code’s retention period (
cleanupPeriodDays, 30 days by default) and were deleted; - the branch name is misspelled.
To keep usage beyond the retention period, record it in the ledger.
3. Read the output
This output was produced by running branch-tally report main against one of branch-tally’s
scrubbed test fixtures, so the session id, dates and token counts are synthetic:
warning: no price for claude-mystery-9 in the price table (as of 2026-11-09); the total is a floor
<!-- token-report:start -->
| Model | Calls | Input | Cache write | Cache read | Output | (thinking) | Est. cost |
|---|--:|--:|--:|--:|--:|--:|--:|
| `claude-opus-5[1m]` | 2 | 7,654,324 | 930,894 | 98,765,443 | 123,464 | 0 | $96.76 |
| `claude-sonnet-4-6` | 2 | 1,235,344 | 269,356 | 4,569,002 | 89,456 | 0 | $7.51 |
| `claude-fable-5-1` | 2 | 112,344 | 20,428 | 3,543,209 | 6,789 | 0 | $2.66 |
| `claude-mystery-9` | 1 | 11,111 | 499 | 3,333 | 2,222 | 0 | — |
| **Total** | **7** | **9,013,123** | **1,221,177** | **106,880,987** | **221,931** | **0** | **at least $106.93** |
| Session | Where | Agents | Span (UTC) | Calls | Tokens |
|---|---|---|---|--:|--:|
| `s1` | main checkout | — | 01-01 00:00 → 01-01 00:00 | 7 | 117.3M |
<sub>Branch `main` · 1 session(s), 0 in a worktree · 0 of 7 calls from subagents · prices as of 2026-11-09 (bundled table) · 1 model(s) unpriced, total is a floor · est. cost at list API rates, not amounts billed</sub>
<details><summary>Breakdown by skill / agent</summary>
| Attribution | Calls | Tokens |
|---|--:|--:|
| main | 7 | 117,337,218 |
</details>
<!-- token-report:end -->
The first line went to stderr; everything else is stdout. Piece by piece:
- The markers.
<!-- token-report:start -->and<!-- token-report:end -->wrap the report, soreport --prcan find and replace it in a pull request body. In a PR they are invisible. - The model table. One row per model: API calls, then the token counts the API reported. “Cache write” adds the 5-minute and 1-hour cache writes, which are priced differently; the JSON output keeps them apart. “(thinking)” is thinking tokens, shown separately where the transcript records them. “Est. cost” is the estimate at list API rates.
- A floor.
claude-mystery-9is not in the price table, so its cost is—, stderr names it, and the total reads “at least $106.93”. The tokens are still counted. - The session table. One row per Claude Code session that contributed: where it ran (the main checkout or a worktree), the named agents it used, the UTC time span, its calls and its tokens in millions.
- The footnote. The branch, how many sessions ran in a worktree, how many calls came from subagents, which attribution heuristics were used (with their call and token counts), and the price table’s date and source.
- Breakdown by skill / agent. The same tokens split by the skill or named agent that made the
calls.
mainis the session’s own top-level work.
When subagents did part of the work, a Subagent usage section follows, with one row per subagent, the workspace it ran in and its cost, plus a line saying how many subagent calls had no dispatch link. When some calls could not be assigned to a branch with certainty, a bold line says “Token total is at least …” and how many tokens were ambiguous.
Next steps
- Put the table on your pull request:
branch-tally report --pr --dry-runshows the new PR body, andbranch-tally report --prwrites it. You need the GitHub CLI installed and signed in, and an open PR for the branch. - Get machine-readable output with
--json. - Choose how calls are assigned to branches with
--mode(see Attribution modes). - Save your preferred defaults in a config file.
- Keep the numbers after transcripts are purged with the ledger, and let the optional Claude Code plugins record it for you at the end of each session.
Installation
branch-tally is a single static binary. Every release on the releases page ships it prebuilt for six targets:
| Platform | Target | Archive |
|---|---|---|
| Linux x86_64 | x86_64-unknown-linux-musl | .tar.gz |
| Linux aarch64 | aarch64-unknown-linux-musl | .tar.gz |
| macOS Intel | x86_64-apple-darwin | .tar.gz |
| macOS Apple silicon | aarch64-apple-darwin | .tar.gz |
| Windows x64 | x86_64-pc-windows-msvc | .zip |
| Windows arm64 | aarch64-pc-windows-msvc | .zip |
The Linux builds are statically linked against musl, so they run on any distribution, whatever its C library.
Choose a method:
| Method | Page |
|---|---|
One-line installer for Linux and macOS (install.sh) | Linux and macOS |
One-line installer for Windows (install.ps1) | Windows |
Download an archive, check SHA256SUMS and the provenance attestation yourself | Download a release by hand |
| Compile it with cargo | Build from source |
The installers download the archive for your platform from GitHub, check it against the release’s
SHA256SUMS and copy the binary into place. They never edit your shell startup files or your
PATH.
Package managers (crates.io, Homebrew, npm) are not available yet; they are tracked on the roadmap.
Upgrading and uninstalling
To upgrade, run the installer again. It replaces the existing binary in one step. With a manual install, replace the file yourself.
To uninstall, delete the binary (~/.local/bin/branch-tally, or
%USERPROFILE%\.local\bin\branch-tally.exe, unless you chose another directory). branch-tally
also may have written these files, which you can delete:
- the user config file, if you ran
config init(see Config reference); - the price cache, if you ran
prices update(see CLI reference).
Linux and macOS
install.sh installs the latest release for Linux (x86_64, aarch64) and macOS (Intel, Apple
silicon):
curl -fsSL https://github.com/IAmSkweetis/branch-tally/releases/latest/download/install.sh | sh
On success it prints the version and target it is installing, the installed path, and the
installed binary’s --version:
Installing branch-tally v0.6.1 for x86_64-unknown-linux-musl
Installed /home/you/.local/bin/branch-tally
branch-tally 0.6.1
What it does
- Works out the target from
uname. Linux always gets the static musl build. On macOS, a shell running under Rosetta on an Apple silicon Mac still gets the Apple silicon build. - Resolves “latest” to a concrete version by reading the release’s
SHA256SUMS, without calling the GitHub API. Everything after that is downloaded from that one version, so a release published mid-install can’t mix two versions. - Downloads
SHA256SUMSandbranch-tally-<version>-<target>.tar.gz, and checks the archive’s SHA-256 against its line inSHA256SUMS. A mismatch, or an archive missing fromSHA256SUMS, stops the install and nothing is written. - Extracts the binary and moves it over any existing
branch-tallyin the install directory in a single rename. - Runs the installed binary’s
--versionto prove it works. - If the install directory is not on your
PATH, prints the line to add to your shell profile. It never edits a startup file.
It needs only tools a stock system has: curl or wget, sha256sum or shasum, and tar.
The installer does not verify the provenance attestation. To do that, see Download a release by hand.
Options
Every option has a flag and an environment variable. A flag overrides its variable.
| Flag | Environment variable | Default | Meaning |
|---|---|---|---|
--version <vX.Y.Z> | BRANCH_TALLY_INSTALL_VERSION | latest | Install this release instead of the latest. The leading v is optional. |
--to <dir> | BRANCH_TALLY_INSTALL_DIR | $HOME/.local/bin | Install into this directory. It is created if missing. |
| none | BRANCH_TALLY_INSTALL_BASE_URL | https://github.com/IAmSkweetis/branch-tally/releases | Download from a mirror with the same layout (<base>/latest/download/… and <base>/download/<tag>/…). |
-h, --help | none | Print the options and exit. |
A version must be of the form vMAJOR.MINOR.PATCH; anything else is an error.
To pass flags through the pipe, use sh -s --:
curl -fsSL https://github.com/IAmSkweetis/branch-tally/releases/latest/download/install.sh \
| sh -s -- --version v0.6.1 --to ~/bin
Or set the variables:
curl -fsSL https://github.com/IAmSkweetis/branch-tally/releases/latest/download/install.sh \
| BRANCH_TALLY_INSTALL_DIR="$HOME/bin" sh
You can also download install.sh from a release, read it, and run it as a file:
sh install.sh --help.
Errors
The installer prints install.sh: error: <reason> and exits with status 1 when, among other
things:
- an option is unknown or a version is malformed;
- the OS or architecture is not one of the supported targets (from a Windows shell such as Git Bash, it tells you to use the PowerShell installer instead);
curl/wget,sha256sum/shasumortaris missing;- the latest release has no build for your target;
- a download fails, the checksum does not match, or the archive is not listed in
SHA256SUMS; - the install directory cannot be created or written;
- the installed binary does not run.
HOME must be set unless you pass --to.
Windows
install.ps1 installs the latest release for Windows on x64 or arm64. Run it from PowerShell:
irm https://github.com/IAmSkweetis/branch-tally/releases/latest/download/install.ps1 | iex
If your execution policy refuses it, run it through a one-off bypass:
powershell -ExecutionPolicy Bypass -c "irm https://github.com/IAmSkweetis/branch-tally/releases/latest/download/install.ps1 | iex"
Windows PowerShell 5.1 and PowerShell 7 both work. The installer needs nothing beyond built-in PowerShell.
What it does
- Picks the build for the machine’s OS architecture, not the PowerShell process’s, so a 32-bit or emulated PowerShell on an arm64 machine still gets the arm64 build.
- Resolves “latest” to a concrete version by reading the release’s
SHA256SUMS, without calling the GitHub API. Everything after that is downloaded from that one version. - Downloads
SHA256SUMSandbranch-tally-<version>-<target>.zip, and checks the archive’s SHA-256 against its line inSHA256SUMS. A mismatch, or an archive missing fromSHA256SUMS, stops the install and nothing is written. - Extracts
branch-tally.exeand moves it over any existing copy in the install directory in one step. If the old binary is running, the install fails withcannot replace … (is branch-tally running?). - Runs the installed binary’s
--versionto prove it works. - If the install directory is not on your
PATH, prints two commands: one that adds it for your user (new terminals pick it up), and one that adds it to the current terminal only. It never edits yourPATHitself.
The installer does not verify the provenance attestation. To do that, see Download a release by hand.
Options
Every option has a parameter and an environment variable. A parameter overrides its variable.
| Parameter | Environment variable | Default | Meaning |
|---|---|---|---|
-Version <vX.Y.Z> | BRANCH_TALLY_INSTALL_VERSION | latest | Install this release instead of the latest. The leading v is optional. |
-To <dir> | BRANCH_TALLY_INSTALL_DIR | %USERPROFILE%\.local\bin | Install into this directory. It is created if missing. |
| none | BRANCH_TALLY_INSTALL_BASE_URL | https://github.com/IAmSkweetis/branch-tally/releases | Download from a mirror with the same layout. A file:// URL also works. |
A version must be of the form vMAJOR.MINOR.PATCH; anything else is an error.
irm | iex cannot pass parameters, so with the one-liner use the environment variables:
$env:BRANCH_TALLY_INSTALL_VERSION = 'v0.6.1'
$env:BRANCH_TALLY_INSTALL_DIR = 'C:\tools\bin'
irm https://github.com/IAmSkweetis/branch-tally/releases/latest/download/install.ps1 | iex
To use the parameters, download install.ps1 from a release and run it as a file:
powershell -ExecutionPolicy Bypass -File .\install.ps1 -Version v0.6.1 -To C:\tools\bin
Get-Help .\install.ps1 prints the script’s own description of the options.
Errors
The installer writes install.ps1: error: <reason> to stderr when, among other things:
- a version is malformed;
- it is run on something other than Windows (use the Linux and macOS installer there), or the architecture is not x64 or arm64;
- the latest release has no build for your target;
- a download fails, the checksum does not match, or the archive is not listed in
SHA256SUMS; - the existing binary cannot be replaced;
- the installed binary does not run.
Run as a file, it exits with status 1. Under irm | iex it does not exit, because that would close
your terminal; it sets $LASTEXITCODE to 1 instead.
Download a release by hand
Every release on the releases page attaches:
- six archives, one per target, named
branch-tally-<vX.Y.Z>-<target>.tar.gz(.zipfor Windows). The targets are listed on the Installation page; install.shandinstall.ps1;SHA256SUMS, with one SHA-256 line for each archive and each installer.
Every archive and installer also has a GitHub build provenance attestation, which proves it was built by this repository’s release workflow from the tagged commit.
1. Download
Pick the archive for your platform and download it together with SHA256SUMS. For example, for
v0.6.1 on Linux x86_64:
curl -fsSLO https://github.com/IAmSkweetis/branch-tally/releases/download/v0.6.1/branch-tally-v0.6.1-x86_64-unknown-linux-musl.tar.gz
curl -fsSLO https://github.com/IAmSkweetis/branch-tally/releases/download/v0.6.1/SHA256SUMS
2. Check the checksum
On Linux:
sha256sum --check --ignore-missing SHA256SUMS
--ignore-missing skips the lines for the files you did not download. On macOS, check only the
archive’s line:
grep 'aarch64-apple-darwin.tar.gz' SHA256SUMS | shasum -a 256 -c
Either prints <archive name>: OK for a good download.
On Windows, in PowerShell, compare the hash with the archive’s line in SHA256SUMS:
(Get-FileHash .\branch-tally-v0.6.1-x86_64-pc-windows-msvc.zip -Algorithm SHA256).Hash.ToLower()
Select-String 'x86_64-pc-windows-msvc.zip' .\SHA256SUMS
A checksum taken from the same release only proves the download is complete and uncorrupted. For proof of where it came from, check the attestation.
3. Check the attestation
With the GitHub CLI installed and signed in:
gh attestation verify branch-tally-v0.6.1-x86_64-unknown-linux-musl.tar.gz --repo IAmSkweetis/branch-tally
It exits 0 when the archive’s attestation is valid and was issued for this repository, and
non-zero otherwise. The same command works for install.sh and install.ps1.
4. Install
Each archive holds one directory, branch-tally-<vX.Y.Z>-<target>/, containing the binary
(branch-tally, or branch-tally.exe on Windows), LICENSE and README.md.
tar -xzf branch-tally-v0.6.1-x86_64-unknown-linux-musl.tar.gz
mkdir -p ~/.local/bin
cp branch-tally-v0.6.1-x86_64-unknown-linux-musl/branch-tally ~/.local/bin/
branch-tally --version
On Windows, extract the .zip (for example with Expand-Archive) and copy branch-tally.exe to a
directory on your PATH.
Any directory on your PATH will do; ~/.local/bin is only the default the installers use.
Build from source
branch-tally is not published to crates.io yet, so cargo install branch-tally does not work.
Build it from a clone of the repository instead.
Requirements
- rustup. The repository pins its Rust toolchain (1.99.1) in
rust-toolchain.toml, and rustup installs that version automatically the first time you build inside the clone. - A C compiler. One dependency (
ring, used for TLS byprices update) compiles C code. git.
Build and install
git clone https://github.com/IAmSkweetis/branch-tally.git
cd branch-tally
cargo install --path crates/branch-tally --locked
--locked builds with the exact dependency versions in the repository’s Cargo.lock. cargo puts
the binary in ~/.cargo/bin (%USERPROFILE%\.cargo\bin on Windows), which rustup normally adds to
your PATH. Pass --root <dir> to install somewhere else; the binary goes in <dir>/bin.
To build without installing:
cargo build --release --locked -p branch-tally
The binary is then target/release/branch-tally (.exe on Windows).
The version of a source build
A source build reports its version as 0.0.0-dev:
$ branch-tally --version
branch-tally 0.0.0-dev
Release builds get their version from the release tag at build time. To build a specific
release, check out its tag first (git checkout v0.6.1); the version string still reads
0.0.0-dev.
main may hold changes that are not in a release yet. This manual documents main.
Integration
branch-tally’s repository is also a Claude Code plugin marketplace. It offers two optional plugins that run branch-tally from Claude Code hooks, so the numbers keep working after Claude Code purges old transcripts and after worktrees are removed.
| Plugin | Runs on | Runs | Gives you |
|---|---|---|---|
branch-tally-capture | SessionStart, SubagentStop, Stop | branch-tally capture | A record of which branch each session was on, so calls whose transcript stamp is missing can still be attributed. See Hook captures. |
branch-tally-record | SessionEnd | branch-tally record | The ledger filled in at the end of every session, before the transcripts can be purged. |
They are separate plugins so you can install, disable or remove each one on its own. Claude Code can turn off a whole plugin but not one hook inside it.
Two more integrations are planned and not available yet:
- A Claude Code skill to record and publish a pull request’s usage. Tracked in #11.
- A GitHub Action for agent workflows. Tracked in #12.
Until they ship, publish the table yourself with branch-tally report --pr. See the
CLI reference.
Before you install
- Install the binary first. Both plugins call
branch-tallyby name, so it must be on thePATHthat Claude Code’s hooks see. See Installation. Without it the hooks do nothing. - Start Claude Code inside the repository. The hooks run in the session’s working directory. In a directory that is not a git checkout they find nothing to record.
Install
In Claude Code, add the marketplace once, then install the plugins you want:
/plugin marketplace add IAmSkweetis/branch-tally
/plugin install branch-tally-capture@branch-tally
/plugin install branch-tally-record@branch-tally
Install either one, both, or neither. To stop one later, disable or uninstall it:
/plugin disable branch-tally-record@branch-tally
/plugin uninstall branch-tally-record@branch-tally
What the hooks do
flowchart LR
S[SessionStart, Stop,<br/>SubagentStop] -- capture plugin --> C[".tally/captures.jsonl<br/>(main checkout)"]
E[SessionEnd] -- record plugin --> L[".tally/ledger.jsonl"]
C -. "read by report, record" .-> R[report]
L -. "report --from-ledger" .-> R
branch-tally-capture runs branch-tally capture when a session starts, when a subagent
finishes and when the main agent stops. Each run appends one line to .tally/captures.jsonl in the
repository’s main checkout: the session id, the agent id for a subagent, the branch, the kind of
event and the time. Hook captures covers the file and how reports use it.
branch-tally-record runs branch-tally record when a session ends. That appends the current
branch’s attributed usage to .tally/ledger.jsonl, exactly as running record by hand would:
- It records the branch that the session’s directory has checked out when the session ends, in the
attribution mode from your config file (
claimby default). - The ledger it writes is at the root of the checkout the session ran in. A session in a git worktree writes the worktree’s own ledger, which merges back with the worktree’s branch.
- It writes the file only. It does not
git addor commit. Commit the ledger yourself, as described in Persisting the Ledger. - It is safe to run every session.
recordappends only the calls the ledger does not already hold.
The hooks never block a session
Every hook command ends in ; exit 0, for example branch-tally capture; exit 0. That works in
both Bash and PowerShell, and it means a hook always exits 0, whatever happened: a missing binary,
a directory that is not a repository, a detached HEAD, a branch with no usage yet, or a usage
error. Claude Code never sees a failure and never blocks Stop or SubagentStop.
The three capture hooks each set a 30-second timeout. The record hook sets none, because a
plugin can’t change how long SessionEnd gets. That limit is covered next.
SessionEnd time limit
Claude Code gives SessionEnd hooks 1.5 seconds by default. A timeout on a hook that a plugin
provides does not raise that budget. Only a timeout in your own settings files, or an environment
variable, does. branch-tally record reads all of the repository’s transcripts, so a long history
can take longer than 1.5 seconds. To give it more time, do one of these:
- Set
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS, in milliseconds, before you start Claude Code. For example,CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=10000 claudeallows 10 seconds. This needs Claude Code v2.1.268 or later. Before that, the variable raised only the overall budget, and a hook with notimeoutof its own, like the record plugin’s, was still stopped after 1.5 seconds. - Add your own
SessionEndhook that runsbranch-tally record; exit 0with atimeout(up to 60 seconds) in your settings file, instead of installing the record plugin.
These rules belong to Claude Code, not branch-tally. Check the SessionEnd section of the
Claude Code hooks reference for the current behaviour.
If Claude Code stops record before it finishes, the usage it missed can still be recorded.
record appends only the calls the ledger does not already hold, so the next SessionEnd, or a
branch-tally record you run by hand, writes the rest, provided it runs before the transcripts are
purged. If the stop cut the ledger’s last line short, reports skip that
line, count it as malformed in a note: skipped N ledger line(s) message on stderr, and carry on.
The next record starts on a fresh line and writes the cut entry again whole. Until it does, a
report from the ledger is missing that call. The total is then lower than the real cost, but the
report does not label it “at least” for this reason: the stderr note is the signal.
Which shell runs the hooks
Claude Code runs each hook command through a shell: sh -c on macOS and Linux, Git Bash on
Windows, or PowerShell on Windows when Git Bash is not installed. ; exit 0 works in all of them.
If you copy a hook into your own settings, its shell field ("bash" or "powershell") picks one
explicitly.
An error can still print a line on stderr. branch-tally capture prints one short fixed message
and never echoes the payload or the working directory.
Privacy
The hooks read only the hook payload Claude Code passes on stdin. capture reads its session id,
event name, agent id, agent type and working directory, and ignores every other field. It stores
no path: it asks git which branch the working directory is on and keeps only the branch name.
record stores what the ledger stores. Neither writes message
content.
Both files are designed to be committed, so anyone who can read the repository can read the session ids, agent ids and branch names in them.
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"}
| Field | Holds |
|---|---|
v | The captures schema version, currently 1. |
harness | The agent harness, claude-code. |
kind | The hook moment: session-start, subagent-stop or stop. |
session_id | The Claude Code session id. |
agent_id | The subagent’s id. Present on subagent-stop lines. |
branch | The branch checked out in the session’s working directory at that moment. |
worktree_branch | subagent-stop only: the branch of the subagent’s worktree, when one is found. |
ts | When 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.
Persisting the Ledger
By default branch-tally report reads Claude Code’s transcripts live. That has limits:
- Transcripts are purged. Claude Code deletes them after
cleanupPeriodDays(30 days by default). A branch that took longer than that, or a report run after the cleanup, comes up short. - Transcripts are local. Work on a branch often happens on more than one machine. Each machine only sees its own sessions.
- Worktrees come and go. Subagent work in a git worktree is easiest to attribute while the worktree still exists.
The ledger fixes that. branch-tally record writes a branch’s attributed usage to a file in the
repository, .tally/ledger.jsonl. You commit that file like any other. branch-tally report --from-ledger then reports from it, with or without the transcripts.
flowchart LR
subgraph A[Machine A]
TA[transcripts] -- "branch-tally record" --> LA[".tally/ledger.jsonl"]
end
subgraph B[Machine B]
TB[transcripts] -- "branch-tally record" --> LB[".tally/ledger.jsonl"]
end
LA -- "git commit, push" --> M["feature branch<br/>(merge=union keeps both sides' lines)"]
LB -- "git commit, push" --> M
M -- "branch-tally report --from-ledger" --> R[report]
Set up your repository
Add this line to your repository’s .gitattributes:
.tally/ledger.jsonl merge=union
merge=union tells git to keep both sides’ lines when two branches have each appended to the
ledger, instead of reporting a conflict. branch-tally does not add this line for you. Without it,
two branches that both recorded will conflict when merged.
If you use a different path with --ledger, put that path in .gitattributes instead.
If you commit the hook captures file too, give it the same treatment:
.tally/captures.jsonl merge=union.
Record
branch-tally record # the current branch
branch-tally record feat/thing # a named branch
record selects the branch’s usage exactly as report would, then appends one line per API call
to the ledger:
appended 3, skipped 0, branch feat/x
- It only writes the file. It does not
git addor commit anything. Commit the ledger yourself. - It is safe to run again. Each line is keyed by the API call’s request id and the branch it
was recorded for.
recordappends only keys the ledger does not hold yet, so a second run reportsappended 0, skipped 3. - It creates the file and the
.tally/directory when they are missing. - It stores the mode.
recordtakes the same--mode,--strict,--timelineand--work-branchoptions asreport, and the config file’smodeandwork-branch. Each line stores the mode it was selected under. - In claim mode, one API call can count toward two branches. It is then recorded once for each branch you record.
Run record on every machine that did work on the branch, before its transcripts are purged,
and while any worktrees the work used still exist. Then commit and push the ledger.
The full option list is in the CLI reference.
Record automatically when a session ends
Remembering to run record before cleanupPeriodDays runs out is easy to get wrong. The optional
branch-tally-record Claude Code plugin does it for you: its SessionEnd hook runs
branch-tally record every time a session in the repository ends.
/plugin marketplace add IAmSkweetis/branch-tally
/plugin install branch-tally-record@branch-tally
The hook records the branch the session’s directory has checked out when the session ends, with
the mode and work-branch from your config file. It is the same command as above,
so the same rules hold: it only writes the file, a second run appends nothing new, and you still
commit and push the ledger yourself. It needs branch-tally on your PATH, and it never blocks
the session, even when record fails.
Claude Code gives a SessionEnd hook 1.5 seconds by default, and a plugin can’t raise that.
record reads every transcript for the repository, so a long history can need more. Set
CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS (in milliseconds) or use a hook in your own settings with
a timeout, as described in SessionEnd time limit. If
record is stopped part way, it catches up later: it appends only what the ledger does not already
hold, so the next session end, or a record by hand, writes the rest, provided it runs before the
transcripts are purged.
A branch worked on from several sessions, or with sessions that ended on another branch, may
still need a record by hand for the branches the hook did not see. Installing the plugin, and
the companion branch-tally-capture plugin, is covered in Integration.
Report from the ledger
branch-tally report --from-ledger
branch-tally report --from-ledger --with-live
--from-ledger reports only from the ledger’s lines recorded for the requested branch. It works
on a machine with no transcripts at all, such as a fresh clone. Every other report option still
applies: --json, --pr, --redact and the rest.
--with-live also reads this machine’s live transcripts and combines them with the ledger. When a
call appears in both, the ledger’s line is used, so nothing is counted twice. Use it when part of
the work is recorded and part is still only in local transcripts. --with-live without
--from-ledger is a command-line error (exit 2).
Prices are applied when you report, not when you record. The ledger stores token counts, so a report from the ledger uses the price table in effect at report time.
What a ledger report tells you on stderr, as counts only:
| Note | Meaning |
|---|---|
note: unattributed counts unavailable from the ledger | A live report can say how many calls in the repository could not be attributed to any branch. The ledger only holds the requested branch’s calls, so it can’t. |
note: ledger mixes modes (claim, timeline); the numbers combine them | The branch was recorded under more than one attribution mode. |
note: skipped N ledger line(s): … | Lines that were malformed or written by an unsupported ledger version were skipped and counted. |
note: N ledger request(s) had conflicting lines; kept the latest | Two lines with the same key held different data. The one with the latest recorded_at is used. |
A duplicate line, such as one left by a union merge or by recording twice, is collapsed silently: it is not a conflict.
If the ledger has no lines for the branch, report --from-ledger fails with
No ledger records found for branch '…' (exit 1). If the ledger file does not exist, it fails with
no ledger at <path> (exit 1).
Where the file lives
The default is .tally/ledger.jsonl at the root of the git checkout that contains the current
directory (or the --repo directory). Run from inside a git worktree, that is the worktree’s own
root, so the ledger is committed on the worktree’s branch and merges back with it.
--ledger <path> uses another file, on both record and report --from-ledger. Outside a git
checkout, --ledger is required: without it, record and report --from-ledger fail (exit 1)
rather than write a stray ledger.
--ledger only has an effect on report together with --from-ledger.
What is stored
One JSON object per line, append-only. A line holds:
| Field | Holds |
|---|---|
v | The ledger schema version, currently 1. |
request_id | The API call’s request id. With recorded_for, the line’s key. |
harness | The agent harness, claude-code. |
session_id | The Claude Code session id. |
agent_id | The subagent’s id, when a subagent made the call. |
ts | The call’s timestamp. |
model | The model id. |
recorded_for | The branch record was run for. |
mode | The attribution mode: claim, strict or timeline. |
branch | The branch attribution assigned to the call. |
basis | How that branch was decided, such as stamped by the transcript, rolled up from a subagent’s dispatcher, or captured from hook captures. |
kept_by | Why the call was kept for the branch: its own branch matched, or its session was claimed. |
skill, agent | The skill and named agent that made the call, if any. |
subagent | The subagent’s workspace and ids, for subagent calls. |
origin | Where the session ran: "main" for the main checkout, or {"worktree": "<name>"} with the worktree’s name relative to the repository. |
usage | Token counts: input, 5-minute and 1-hour cache writes, cache reads, output and thinking. |
recorded_at | When the line was written, in UTC. |
What is never stored: message content (prompts, responses, tool input or output, code), the
working directory, or any path. A worktree’s transcript directory name encodes its full path,
including your user name, so the ledger keeps only the part after the main checkout’s name. A
worktree outside the main checkout is stored without a name, and record says so:
note: N record(s) from a worktree outside the main checkout were stored without a worktree name.
Redaction and the ledger
The ledger stores real identifiers: session ids, agent ids, skill and agent names and full
timestamps. record has no redaction option, and the config file’s redact setting does not
apply to it. Anyone who can read the repository can read those identifiers.
--redact applies to report output, including a report from the ledger:
branch-tally report --from-ledger --redact hides the identifiers in what it prints, while the
file itself is unchanged.
CLI reference
branch-tally <COMMAND>
| Command | Does |
|---|---|
report | Report Claude Code token usage for a git branch. |
record | Append this branch’s attributed usage to the repository ledger (write only). |
capture | Record this session’s branch from a Claude Code hook payload on stdin. Run by the capture plugin, not by hand. |
prices update | Refresh the per-user price cache from the LiteLLM model price list. |
prices show | Show which price table reports would use, with its source, date and path. |
config show | Show the config file path, whether it was found, and each effective value and its source. |
config init | Write a config file holding the built-in defaults. Never overwrites an existing file. |
help [COMMAND] | Print the help for the tool or a command. |
Global options:
| Option | Does |
|---|---|
-h, --help | Print help. Every command and subcommand accepts it. |
-V, --version | Print the version, such as branch-tally 0.6.1. A build from source prints 0.0.0-dev. |
Exit codes
| Code | Meaning |
|---|---|
0 | Success. |
1 | The command failed. A one-line message on stderr says why: no records for the branch, a bad config file, a missing ledger, a gh failure, and so on. |
2 | The command line is wrong: an unknown flag, a bad value, two conflicting flags, or a flag that needs another one. A usage message goes to stderr. |
Output streams
The report, JSON and command results go to stdout, written as UTF-8 with LF line endings on every platform. Warnings and notes go to stderr, so you can redirect the report to a file and still see them. Notes and warnings carry counts, model ids, branch names and paths only, never transcript content.
branch-tally report
branch-tally report [OPTIONS] [BRANCH]
Reports the token usage and estimated cost for BRANCH (default: the current branch).
| Option | Default | Does |
|---|---|---|
[BRANCH] | current branch | The branch to report on. |
--repo <PATH> | . | The repository to report on. Any directory inside its main checkout or one of its worktrees works. |
--mode <MODE> | claim | Attribution mode: claim, strict or timeline. See Attribution modes. |
--strict | Same as --mode strict. | |
--timeline | Same as --mode timeline. | |
--work-branch <REGEX> | see below | Timeline mode only: a regex naming the work branches. |
--format <FORMAT> | md | Output format: md or json. See Output formats. |
--md | Same as --format md. | |
--json | Same as --format json. | |
--pr [<N>] | Splice the table into pull request N, or this branch’s open PR when N is omitted. See Updating a pull request. | |
--dry-run | With --pr, print the new PR body without editing the PR. | |
--cost | on | Show the estimated-cost column. Undoes cost = false in the config file. |
--no-cost | Omit the estimated-cost column from the Markdown table. | |
--redact | off | Hide identifiers in the output. See Redaction. |
--no-redact | Show identifiers. Undoes redact = true in the config file. | |
--keep-names | With redaction, keep real agent and skill names; ids and times are still hidden. | |
--redact-names | on | With redaction, replace agent and skill names with generic labels. Undoes redact-names = false in the config file. |
--config <PATH> | user config path | Read this config file instead of the default location. It must exist. |
--from-ledger | Report from the ledger instead of the live transcripts. Works with no transcripts. | |
--with-live | With --from-ledger, also read this machine’s live transcripts. The ledger wins where both have a call, so nothing is counted twice. | |
--ledger <PATH> | .tally/ledger.jsonl | The ledger file, used with --from-ledger. The default is at the root of the current checkout. |
Each flag that has a config file key overrides it for one run. Precedence is flag, then config file, then built-in default.
Conflicting flags
These are command-line errors (exit 2):
- a shorthand together with its long form, even when they agree:
--strictor--timelinewith--mode,--mdor--jsonwith--format; --strictwith--timeline, and--mdwith--json;- both halves of an on/off pair:
--costwith--no-cost,--redactwith--no-redact,--keep-nameswith--redact-names; --with-livewithout--from-ledger;--work-branchwhen the effective mode is not timeline. The mode can come from--mode timeline,--timelineor the config file’smode = "timeline". Awork-branchset only in the config file is ignored outside timeline mode rather than being an error.
A few flags are accepted but have no effect in some combinations:
--dry-runwithout--prdoes nothing.--ledgerwithout--from-ledgerdoes nothing.--prwith JSON output (--json,--format jsonorformat = "json"in the config file) prints the JSON and does not touch the PR.--pronly works with Markdown.--no-costaffects the Markdown table only. JSON output always includes costs.--keep-namesand--redact-namesdo nothing unless redaction is on.
Attribution modes
A transcript stamps each API call with the git branch the session was on. That stamp is often not the branch the work was for: a session switches branches, a subagent in a worktree stamps the worktree’s throwaway branch, or a call carries no branch at all. The mode decides how branch-tally assigns calls to branches.
claim (the default). Starts from each call’s stamp, then:
- places a session’s own call that has no stamp, or a
worktree-agent-…stamp, on the branch the session’s hook captures name both before and after that call (captured hook data). A call with captures on one side only is not placed this way. This needs the capture plugin, and wins over the worktree and origin recovery below; - recovers the branch of a subagent call stamped
worktree-agent-…from the worktree it ran in (worktree recovery), and of an unstamped call read from a worktree’s transcripts from that worktree (origin recovery), usinggit worktree list, so the worktree must still exist; - assigns subagent calls to the branch of the session that dispatched them (dispatcher roll-up);
- counts a whole session toward a branch when that branch is the only work branch the session
touched (session claim). A work branch here is one starting with
feat/,feature/,fix/,bugfix/,hotfix/,chore/orrelease/. So a session that started onmainand then moved tofeat/xcounts entirely towardfeat/x.
strict. Keeps only the calls whose stamp is the branch: no session claim, no captured hook
data, no worktree recovery and no dispatcher roll-up. This is the most literal filter, and it
usually counts less than claim.
timeline. Follows each session’s branch over time instead of giving it one branch. A call
stamped with a work branch stays there. A call stamped with something else (the default branch,
a worktree-agent-… branch) goes to the work branch the session was last on, or, before the
session’s first work-branch call, to the first one it moves to. Subagent calls go to the
dispatcher’s branch at the moment of dispatch. A call with no usable timestamp, or two work
branches at the same instant, is ambiguous: it keeps its raw stamp and is reported as such. There
is no worktree lookup in this mode, and hook captures are not used.
In timeline mode, a work branch is by default every branch except the repository’s default branch
and worktree-agent-*. The default branch is read from origin/HEAD, then from git’s
init.defaultBranch, and is otherwise assumed to be main; the footnote says which. A
worktree-agent-* branch is never a work branch. --work-branch <REGEX> replaces that rule. The
regex is matched anywhere in the branch name, as written, so anchor it with ^ and $ to match
the whole name:
branch-tally report --timeline --work-branch '^(feat|fix)/'
Every heuristic that assigned calls is listed in the report’s footnote with its call and token
counts, and in the JSON heuristics object. When calls were ambiguous, the report says the token
total is “at least” the figure shown.
Output formats
Markdown (--format md, the default) is the table shown in
Getting Started, wrapped in <!-- token-report:start -->
and <!-- token-report:end --> markers. It has, in order:
- the model table: calls, input, cache write (5-minute and 1-hour combined), cache read, output, thinking and estimated cost per model, then the total;
- the session table: each session’s id, where it ran (main checkout or worktree), its named agents, its UTC time span, calls and tokens (in millions);
- a bold “Token total is at least …” line, when some calls were ambiguous;
- a footnote: the branch and mode, session and subagent counts, the heuristics used, the work-branch rule in timeline mode, whether identifiers were redacted, the price table’s date and source, unpriced models, and a reminder that costs are estimates at list API rates;
- a Subagent usage section, when subagents did part of the work: one row per subagent and model with its workspace, plus how many subagent calls had no dispatch link;
- a collapsed Breakdown by skill / agent table.
With --no-cost, the cost columns are left out.
JSON (--format json) is one object on stdout. Its top-level keys:
| Key | Holds |
|---|---|
branch | The branch reported on. |
mode | "claim", "strict" or "timeline". |
redacted | true when --redact was on. Absent otherwise. |
totalCalls | API calls in the report. |
totalCostUSD | The estimated total cost in US dollars. |
totalCostIsFloor | true when the cost is a lower bound, for example because a model is unpriced. |
unpricedModels | Model ids that had no price. |
priceTable | asOf (the table’s date) and source: "bundled", "cache" or "override". |
heuristics | Calls and tokens per attribution heuristic (sessionClaim, worktreeRecovery, originRecovery, dispatcherRollUp, timelineForward, timelineCarried) and ambiguous, plus isFloor. captured is added only when hook captures placed at least one call. Absent when no heuristic applied. |
subagents | Subagent totals (calls, tokens, dispatched subagents, workspaces, worktrees, unattributed and unlinked counts, cost) and rows, one per subagent and model with token counts and cost. Present when subagents did part of the work. |
sessions | One object per session: id, origin ("main" or the worktree), first and last timestamps, calls, tokens and agents. |
models | One object per model id: calls, input, cacheWrite5m, cacheWrite1h, cacheRead, output, thinking and costUSD (null when unpriced). |
An abridged example, from a scrubbed test fixture:
{
"branch": "main",
"mode": "claim",
"totalCalls": 7,
"totalCostUSD": 106.9266,
"totalCostIsFloor": true,
"unpricedModels": [
"claude-mystery-9"
],
"priceTable": {
"asOf": "2026-11-09",
"source": "bundled"
},
"sessions": [
{
"id": "s1",
"origin": "main",
"first": "2026-01-01T00:00:01.000Z",
"last": "2026-01-01T00:00:07.000Z",
"calls": 7,
"tokens": 117337218,
"agents": []
}
],
"models": {
"claude-mystery-9": {
"calls": 1,
"input": 11111,
"cacheWrite5m": 444,
"cacheWrite1h": 55,
"cacheRead": 3333,
"output": 2222,
"thinking": 0,
"costUSD": null
}
}
}
Updating a pull request
--pr puts the Markdown report into a pull request’s description. It runs the
GitHub CLI, so gh must be installed and signed in.
branch-tally report --pr --dry-run # print the new PR body, change nothing
branch-tally report --pr # write it to this branch's open PR
branch-tally report --pr 42 # write it to PR #42
Without a number, it looks up the open PR whose head is the branch. If the PR body already holds
the <!-- token-report:start --> and <!-- token-report:end --> markers, the text between them is
replaced. Otherwise a ## Token usage section holding the report is appended to the body.
It tells you on stderr what happened: Token usage table written to PR #N.,
PR #N already up to date., or, with --dry-run, the new body on stdout followed by
--- dry run: PR #N not modified ---. With no open PR for the branch it fails with
No open PR found for branch '…'. Open the PR first, then re-run with --pr. (exit 1).
To keep a place for the report in your PR template, add the markers where you want it:
## Token usage
<!-- token-report:start -->
_Not yet recorded._
<!-- token-report:end -->
Redaction
--redact hides identifiers in the report, for Markdown and JSON alike:
- session ids become
s1,s2, … in order of each session’s first call; - agent and skill names become
agent-1,skill-1, … (mainis kept); - subagent ids become
a1,a2, …, and worktree names becomeworktree-1, …; - timestamps are reduced to dates, and the session table’s header reads
Span (UTC date).
Model ids, branch names, token counts and costs are kept. The output says it was redacted: the
footnote includes identifiers redacted, and the JSON has "redacted": true. --no-cost is a
separate switch.
--keep-names keeps the real agent and skill names while ids, worktree names and times stay
hidden.
Redaction applies to the report only. The ledger stores real identifiers.
What the report says on stderr
| Message | Meaning |
|---|---|
warning: no price for <model> in the price table (as of <date>); the total is a floor | A model has no price. Its tokens are counted, its cost is —, and the total is “at least”. |
note: skipped N transcript record(s): … | Transcript lines that could not be used: malformed lines, records without a request id, or synthetic records. Counted, not hidden. |
note: skipped N capture line(s) in .tally/captures.jsonl: … | Hook capture lines that were malformed, written by an unsupported version, or of an unknown kind. Counted, not hidden. Printed by report and record. |
note: git worktree list was unavailable, so worktree recovery was skipped | git worktree list failed, so subagent calls in worktrees could not be recovered. |
| The ledger notes | See Report from the ledger. |
Common errors
| Message (stderr, exit 1) | Cause |
|---|---|
No transcript records found for branch '…'. Transcripts are local and purged after cleanupPeriodDays (default 30). | Nothing for this branch in this machine’s transcripts. |
No Claude Code transcript directory at <path> | ~/.claude/projects does not exist on this machine. |
No ledger records found for branch '…'. … | --from-ledger found no lines for the branch. |
no ledger at <path> | --from-ledger and the ledger file does not exist. |
config file <path> … | The config file is invalid. See Config reference. |
branch-tally record
branch-tally record [OPTIONS] [BRANCH]
Selects BRANCH’s usage (default: the current branch) exactly as report would and appends it to
the ledger. It writes only the ledger file: no git add, no commit. It prints
appended N, skipped M, branch B.
| Option | Default | Does |
|---|---|---|
[BRANCH] | current branch | The branch to record. |
--repo <PATH> | . | The repository. |
--mode <MODE> | claim | Attribution mode: claim, strict or timeline. |
--strict | Same as --mode strict. | |
--timeline | Same as --mode timeline. | |
--work-branch <REGEX> | Timeline mode only: a regex naming the work branches, as for report. | |
--config <PATH> | user config path | Read this config file instead of the default location. It must exist. |
--ledger <PATH> | .tally/ledger.jsonl | The ledger file. The default is at the root of the current checkout. |
The same conflict rules as report apply to the mode flags and --work-branch. From the config
file, record uses mode and work-branch and ignores the rest.
branch-tally capture
branch-tally capture
Run by the capture plugin’s hooks (SessionStart, SubagentStop, Stop), not
by hand. It takes no options. It reads one hook payload from stdin and appends an id-only event to
.tally/captures.jsonl in the main checkout, so a worktree session’s captures land in one
place. The event holds the session id, the agent id, the branch, the event kind and a timestamp.
No path and no message content is stored or printed. The file and how reports use it are described
in Hook captures.
- The branch is the current branch of the payload’s
cwd(the process directory when the payload has none). A detached HEAD records nothing. - For
SubagentStop,worktree_branchis the branch of the worktree directory namedagent-<agent id>ingit worktree list(compared case-insensitively). Failing that, it is the branch of the payload’s own checkout when that is a linked worktree. Otherwise it is absent. It is never guessed. SessionEndandSubagentStartpayloads are accepted and ignored.- It prints nothing on success. On failure (malformed payload, not a git checkout, a detached HEAD,
a git or write error) it prints one short fixed line to stderr and exits 1. The hook command ends
in
; exit 0, so a failure never blocks the session; that also covers a command-line usage error, which exits 2.
branch-tally prices update
branch-tally prices update
Downloads LiteLLM’s public
model_prices_and_context_window.json from GitHub, keeps the Anthropic chat models, and writes
them to the per-user price cache. It replaces the cache only when the download and the conversion
both succeed, so a failed update leaves a good cache in place. It prints the path written, the
table’s date, the model count, and any models skipped for lacking a usable price.
This is the only command that downloads anything. It sends no account or identifying data, only a
branch-tally/<version> User-Agent.
The cache file is branch-tally/prices.toml under:
| OS | Directory |
|---|---|
| Windows | %LOCALAPPDATA% |
| macOS | $HOME/Library/Caches |
| Linux and others | $XDG_CACHE_HOME when set to an absolute path, else $HOME/.cache |
branch-tally prices show
branch-tally prices show [--repo <PATH>]
Prints which price table reports in this repository would use:
source: bundled
as of: 2026-11-09
models: 23
path: (built into the binary)
--repo <PATH> (default .) is the repository whose .tally/prices.toml override is checked.
Which price table is used
The first of these that exists is used whole, with no per-model merging:
.tally/prices.tomlin the repository’s main checkout (the override,source: override);- the per-user cache written by
prices update(source: cache); - the table built into the binary (
source: bundled).
A model id is priced by the longest model key that is a prefix of it, so claude-fable-5-1 also
prices claude-fable-5-1[1m]. A model that matches nothing is unpriced, and the report says so.
A malformed override or cache stops the report with a message naming the file; it is never
skipped silently.
The override and the cache share one TOML format:
| Key | Required | Meaning |
|---|---|---|
as_of | yes | The date the prices were true, quoted: "YYYY-MM-DD". |
[cache_write] five_minute | no, default 1.25 | 5-minute cache writes cost this multiple of the input rate. |
[cache_write] one_hour | no, default 2.0 | 1-hour cache writes cost this multiple of the input rate. |
[cache_read] default_factor | no, default 0.1 | Cache reads cost this fraction of the input rate. |
[models."<id>"] input | yes | Input price, dollars per million tokens. |
[models."<id>"] output | yes | Output price, dollars per million tokens. |
[models."<id>"] cache_read | no | Cache-read price for this model, dollars per million tokens. |
[models."<id>"] cache_write_5m | no | This model’s multiplier, replacing five_minute. |
[models."<id>"] cache_write_1h | no | This model’s multiplier, replacing one_hour. |
A minimal override:
as_of = "2026-11-09"
[models."claude-opus-5"]
input = 5.0 # $ per million tokens
output = 25.0
The bundled table, with the schema in its header, is
crates/branch-tally-core/data/prices.toml.
branch-tally config show
branch-tally config show [--config <PATH>]
Prints the config file’s path, whether it was found, and each setting’s effective value with its
source (config file or default):
config file: /home/you/.config/branch-tally/config.toml
found: no
mode = claim (default)
work-branch = (not set) (default)
format = md (default)
cost = true (default)
redact = false (default)
redact-names = true (default)
A work-branch that is set while the mode is not timeline carries
[inactive: mode is not timeline]. --config <PATH> reads that file instead of the default
location; it must exist. See the Config reference.
branch-tally config init
branch-tally config init [--path <PATH>]
Writes a starter config file holding the built-in defaults to --path <PATH>, or to the default
location that config show prints. It creates missing directories, prints wrote <path>, and
never overwrites an existing file (exit 1). See the Config reference.
Config reference
A config file holds your standing defaults for report’s options, so you don’t have to pass the
same flags every time. It is optional: with no file, every setting has a built-in default.
Precedence
For each setting, the first of these that is set wins:
- a command-line flag;
- the config file;
- the built-in default.
So a flag always overrides the file for one run. Each on/off setting has a flag for both
directions for exactly that reason: cost = false in the file is undone for one run by --cost.
Location
| OS | Path |
|---|---|
| Windows | %APPDATA%\branch-tally\config.toml |
| Linux, macOS and others | $XDG_CONFIG_HOME/branch-tally/config.toml when XDG_CONFIG_HOME is set, non-empty and absolute; otherwise $HOME/.config/branch-tally/config.toml |
macOS uses the same rule as Linux: ~/.config/branch-tally/config.toml unless XDG_CONFIG_HOME
is set.
A missing file at the default location is normal: the defaults apply. --config <path> on
report, record and config show reads that file instead, and then the file must exist (exit 1
if it does not).
Run branch-tally config show to see which path applies on your machine.
Keys
The file is TOML. Every key is optional.
| Key | Type | Default | Flags | Meaning |
|---|---|---|---|---|
mode | string: "claim", "strict" or "timeline" | "claim" | --mode, --strict, --timeline | How calls are assigned to branches. See Attribution modes. |
work-branch | string (a regex) | not set | --work-branch | In timeline mode, which branches count as work branches. Not set means every branch except the default branch and worktree-agent-*. Ignored in the other modes. |
format | string: "md" or "json" | "md" | --format, --md, --json | Output format of report. |
cost | boolean | true | --cost, --no-cost | Show the estimated-cost column in Markdown output. |
redact | boolean | false | --redact, --no-redact | Hide identifiers in the report. See Redaction. |
redact-names | boolean | true | --redact-names, --keep-names | With redact = true, replace agent and skill names with generic labels (true) or keep the real names (false). No effect when redact is off. |
An example that reports in timeline mode, counts only feat/ and fix/ branches as work, and
redacts by default:
mode = "timeline"
work-branch = "^(feat|fix)/"
redact = true
record reads the same file but uses only mode and work-branch; the output settings don’t
apply to it.
A work-branch in the file while the effective mode is not timeline is ignored, not an error.
config show marks it [inactive: mode is not timeline]. A --work-branch flag outside
timeline mode is an error (exit 2).
Errors
A bad config file stops the command with exit 1 and a message that names the file and, where there is one, the key. The message never quotes the file’s content.
| Problem | Message |
|---|---|
| Not valid TOML | config file <path> is not valid TOML: … |
| Unknown key | config file <path>: unknown key `foo` (expected mode, work-branch, format, cost, redact, redact-names) |
| Wrong value | config file <path>: key `format` must be "md" or "json" (and likewise for each key) |
work-branch is not a valid regex | config file <path>: key `work-branch` is not a valid regular expression |
--config file missing | config file <path> does not exist |
| File exists but can’t be read | cannot read config file <path>: … |
The work-branch regex is only checked when it applies, that is in timeline mode.
config show
branch-tally config show
branch-tally config show --config ./team.toml
Prints the path consulted, found: yes or found: no, and one key = value (source) line for
each of mode, work-branch, format, cost, redact and redact-names. The source is
config file or default. With a config file that sets mode, work-branch and redact:
config file: /home/you/.config/branch-tally/config.toml
found: yes
mode = strict (config file)
work-branch = ^feat/ (config file) [inactive: mode is not timeline]
format = md (default)
cost = true (default)
redact = true (config file)
redact-names = true (default)
config init
branch-tally config init
branch-tally config init --path ./branch-tally.toml
Writes a starter file to --path, or to the default location when --path is omitted. It
creates missing directories and prints wrote <path>. It never overwrites an existing file: if the
file exists it fails with config file <path> already exists; not overwritten (exit 1).
The file it writes holds every built-in default, with work-branch as a commented-out example:
# branch-tally user config: standing defaults for `report`.
# Precedence: command-line flag, then this file, then the built-in default.
# Attribution mode: "claim", "strict" or "timeline".
mode = "claim"
# With mode = "timeline", a regex naming the work branches (default: every branch except the
# repository's default branch and worktree-agent-*). Ignored in the other modes.
# work-branch = "^(feat|fix)/"
# Output format: "md" or "json".
format = "md"
# Show the estimated-cost column: true or false.
cost = true
# Redact identifiers in the report: session ids become s1, s2, ... and timestamps become dates.
# Costs stay visible; `cost` is independent. true or false.
redact = false
# With redact = true: replace agent and skill names with generic labels (true), or keep the real
# names (false).
redact-names = true
The values are written out, not left implicit. If a later release changes a built-in default, a
file made by config init keeps the old value until you edit it.
Workflow Examples
How branch-tally fits into real development workflows.
| Example | Shows |
|---|---|
| This repository | An orchestrator session dispatching subagents into git worktrees, merging their branches into a feature branch, and publishing the token report on the pull request before a squash merge. |
This repository
branch-tally is built with Claude Code, and every pull request in its repository reports its own
token cost with branch-tally. This page describes that workflow as the repository runs it today.
The procedures live in the repository’s Claude Code skills (.claude/skills/feature/,
.claude/skills/create-gh-pr/ and .claude/skills/worktree-cleanup/) and in its
pull request template.
The shape of the work
One Claude Code session, the orchestrator, runs in the repository’s main checkout. It does not write the feature itself. It:
- interviews the user about the issue, then cuts a short-lived feature branch from an up-to-date
main, named per Conventional Branch (for examplefeat/issue-4-report-command), and runs the test suite for a baseline; - dispatches specialized subagents, each into its own git worktree under
.claude/worktrees/. The repository sets Claude Code’sworktree.baseReftohead, so each worktree starts from the orchestrator’s current commit; - each subagent cuts its own branch in its worktree, commits there, and never pushes. It hands back a structured report naming its branch and commits;
- merges each subagent’s branch into the feature branch with
git merge --no-ffas it lands, and runs the test suite after every merge; - hands the merged branch to the user to try on real transcripts (dogfooding), and leaves every worktree in place until the pull request has merged.
gitGraph
commit id: "start"
branch feature
checkout feature
commit id: "baseline"
branch agent-core
checkout agent-core
commit id: "core: red test"
commit id: "core: green"
checkout feature
branch agent-cli
checkout agent-cli
commit id: "cli: red test"
commit id: "cli: green"
checkout feature
merge agent-core
merge agent-cli
checkout main
commit id: "squash merge of the PR" type: HIGHLIGHT
In the diagram, feature is the feature branch and agent-core and agent-cli are two
subagents’ branches. Two agents working on disjoint parts run at the same time. The last commit
on main stands for the squash merge: the pull request lands as one new commit whose message
is the PR title, and none of the branch’s own commits become part of main’s history.
Why the numbers come out right
The subagents’ transcripts are written under the main checkout’s transcript directory, inside the
orchestrator’s session, and stamped with the orchestrator’s branch or with a throwaway
worktree-agent-… branch. They are never stamped with the branch the subagent cut. In the default
claim mode, branch-tally:
- assigns each subagent’s calls to the branch the orchestrator was on when it dispatched that subagent (dispatcher roll-up);
- resolves
worktree-agent-…stamps throughgit worktree list(worktree recovery), which only works while the worktree still exists.
That second point is why the report is published before any worktree is removed. The report’s footnote lists both heuristics with the calls and tokens each one assigned.
Publishing the report
When the work is merged into the feature branch and the full gate (just ci) is green, the user
runs /create-gh-pr. That skill:
-
checks the branch is not
main,ghis signed in, the tree is clean andmainhas not moved; -
pushes the feature branch (only the feature branch, never a subagent’s);
-
opens the pull request with
gh pr create. The body starts from the PR template, whose last section is:## Token usage <!-- token-report:start --> _Not yet recorded._ <!-- token-report:end --> -
runs the token report straight away, while every worktree still exists:
just token-report --prThat recipe runs
branch-tally report --prwith the installed release binary, not a build of the branch under review, so the number comes from a published release. branch-tally finds the branch’s open PR and replaces the placeholder between the markers with the table.
The skill states the result as a floor (“at least $X”). It must run on the machine that did the work, within Claude Code’s transcript retention period, from sessions whose working directory was this repository.
sequenceDiagram
actor User
participant O as Orchestrator session
participant A as Subagents (worktrees)
participant BT as branch-tally
participant GH as GitHub
User->>O: /feature
O->>O: cut feature branch from main, baseline tests
O->>A: dispatch into .claude/worktrees/
A->>A: branch, test-first, commit (no push)
A-->>O: report: branch and commits
O->>O: git merge --no-ff each branch, run tests
User->>O: /create-gh-pr
O->>GH: git push feature branch
O->>GH: gh pr create (template with token-report markers)
O->>BT: just token-report --pr
BT->>BT: read local transcripts, attribute, price
BT->>GH: gh pr edit: table between the markers
User->>GH: squash merge
User->>O: /worktree-cleanup
O->>O: remove worktrees and branches
Merging and cleaning up
The pull request is squash-merged, the only merge method the repository allows. Its title is a
Conventional Commit and becomes the commit message on main.
Only after the merge does the user run /worktree-cleanup. It refuses to start until the PR is
merged, and it checks the PR body: if the text between the token-report markers still says
_Not yet recorded._, it asks whether to run the report before removing anything. Because a
squash merge leaves none of a branch’s commits in main’s history, it decides whether each branch
landed by comparing content, not ancestry, and only then removes the worktrees and branches.
The ledger
This repository does not commit a ledger yet. Its reports are read from the live
transcripts on the machine that did the work, at the moment the PR is opened, which is why the
order above matters: report first, clean up after. A repository that needs the numbers to survive
transcript cleanup, or to combine work from several machines, can run branch-tally record and
commit .tally/ledger.jsonl instead, as described in Persisting the Ledger.