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

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. record writes those to the ledger, and report --from-ledger reads them back in place of the live transcripts.
  • Pricing applies the price table.
  • Render prints the Markdown table or JSON, and report --pr splices it into the pull request.

Where to go next

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, so report --pr can 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-9 is 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. main is 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-run shows the new PR body, and branch-tally report --pr writes 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:

PlatformTargetArchive
Linux x86_64x86_64-unknown-linux-musl.tar.gz
Linux aarch64aarch64-unknown-linux-musl.tar.gz
macOS Intelx86_64-apple-darwin.tar.gz
macOS Apple siliconaarch64-apple-darwin.tar.gz
Windows x64x86_64-pc-windows-msvc.zip
Windows arm64aarch64-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:

MethodPage
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 yourselfDownload a release by hand
Compile it with cargoBuild 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:

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

  1. 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.
  2. 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.
  3. Downloads SHA256SUMS and branch-tally-<version>-<target>.tar.gz, and checks the archive’s SHA-256 against its line in SHA256SUMS. A mismatch, or an archive missing from SHA256SUMS, stops the install and nothing is written.
  4. Extracts the binary and moves it over any existing branch-tally in the install directory in a single rename.
  5. Runs the installed binary’s --version to prove it works.
  6. 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.

FlagEnvironment variableDefaultMeaning
--version <vX.Y.Z>BRANCH_TALLY_INSTALL_VERSIONlatestInstall this release instead of the latest. The leading v is optional.
--to <dir>BRANCH_TALLY_INSTALL_DIR$HOME/.local/binInstall into this directory. It is created if missing.
noneBRANCH_TALLY_INSTALL_BASE_URLhttps://github.com/IAmSkweetis/branch-tally/releasesDownload from a mirror with the same layout (<base>/latest/download/… and <base>/download/<tag>/…).
-h, --helpnonePrint 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/shasum or tar is 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

  1. 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.
  2. 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.
  3. Downloads SHA256SUMS and branch-tally-<version>-<target>.zip, and checks the archive’s SHA-256 against its line in SHA256SUMS. A mismatch, or an archive missing from SHA256SUMS, stops the install and nothing is written.
  4. Extracts branch-tally.exe and moves it over any existing copy in the install directory in one step. If the old binary is running, the install fails with cannot replace … (is branch-tally running?).
  5. Runs the installed binary’s --version to prove it works.
  6. 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 your PATH itself.

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.

ParameterEnvironment variableDefaultMeaning
-Version <vX.Y.Z>BRANCH_TALLY_INSTALL_VERSIONlatestInstall this release instead of the latest. The leading v is optional.
-To <dir>BRANCH_TALLY_INSTALL_DIR%USERPROFILE%\.local\binInstall into this directory. It is created if missing.
noneBRANCH_TALLY_INSTALL_BASE_URLhttps://github.com/IAmSkweetis/branch-tally/releasesDownload 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 (.zip for Windows). The targets are listed on the Installation page;
  • install.sh and install.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 by prices 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.

PluginRuns onRunsGives you
branch-tally-captureSessionStart, SubagentStop, Stopbranch-tally captureA record of which branch each session was on, so calls whose transcript stamp is missing can still be attributed. See Hook captures.
branch-tally-recordSessionEndbranch-tally recordThe 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-tally by name, so it must be on the PATH that 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 (claim by 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 add or commit. Commit the ledger yourself, as described in Persisting the Ledger.
  • It is safe to run every session. record appends 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 claude allows 10 seconds. This needs Claude Code v2.1.268 or later. Before that, the variable raised only the overall budget, and a hook with no timeout of its own, like the record plugin’s, was still stopped after 1.5 seconds.
  • Add your own SessionEnd hook that runs branch-tally record; exit 0 with a timeout (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"}
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.

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 add or 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. record appends only keys the ledger does not hold yet, so a second run reports appended 0, skipped 3.
  • It creates the file and the .tally/ directory when they are missing.
  • It stores the mode. record takes the same --mode, --strict, --timeline and --work-branch options as report, and the config file’s mode and work-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:

NoteMeaning
note: unattributed counts unavailable from the ledgerA 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 themThe 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 latestTwo 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:

FieldHolds
vThe ledger schema version, currently 1.
request_idThe API call’s request id. With recorded_for, the line’s key.
harnessThe agent harness, claude-code.
session_idThe Claude Code session id.
agent_idThe subagent’s id, when a subagent made the call.
tsThe call’s timestamp.
modelThe model id.
recorded_forThe branch record was run for.
modeThe attribution mode: claim, strict or timeline.
branchThe branch attribution assigned to the call.
basisHow that branch was decided, such as stamped by the transcript, rolled up from a subagent’s dispatcher, or captured from hook captures.
kept_byWhy the call was kept for the branch: its own branch matched, or its session was claimed.
skill, agentThe skill and named agent that made the call, if any.
subagentThe subagent’s workspace and ids, for subagent calls.
originWhere the session ran: "main" for the main checkout, or {"worktree": "<name>"} with the worktree’s name relative to the repository.
usageToken counts: input, 5-minute and 1-hour cache writes, cache reads, output and thinking.
recorded_atWhen 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>
CommandDoes
reportReport Claude Code token usage for a git branch.
recordAppend this branch’s attributed usage to the repository ledger (write only).
captureRecord this session’s branch from a Claude Code hook payload on stdin. Run by the capture plugin, not by hand.
prices updateRefresh the per-user price cache from the LiteLLM model price list.
prices showShow which price table reports would use, with its source, date and path.
config showShow the config file path, whether it was found, and each effective value and its source.
config initWrite 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:

OptionDoes
-h, --helpPrint help. Every command and subcommand accepts it.
-V, --versionPrint the version, such as branch-tally 0.6.1. A build from source prints 0.0.0-dev.

Exit codes

CodeMeaning
0Success.
1The 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.
2The 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).

OptionDefaultDoes
[BRANCH]current branchThe 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>claimAttribution mode: claim, strict or timeline. See Attribution modes.
--strictSame as --mode strict.
--timelineSame as --mode timeline.
--work-branch <REGEX>see belowTimeline mode only: a regex naming the work branches.
--format <FORMAT>mdOutput format: md or json. See Output formats.
--mdSame as --format md.
--jsonSame 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-runWith --pr, print the new PR body without editing the PR.
--costonShow the estimated-cost column. Undoes cost = false in the config file.
--no-costOmit the estimated-cost column from the Markdown table.
--redactoffHide identifiers in the output. See Redaction.
--no-redactShow identifiers. Undoes redact = true in the config file.
--keep-namesWith redaction, keep real agent and skill names; ids and times are still hidden.
--redact-namesonWith redaction, replace agent and skill names with generic labels. Undoes redact-names = false in the config file.
--config <PATH>user config pathRead this config file instead of the default location. It must exist.
--from-ledgerReport from the ledger instead of the live transcripts. Works with no transcripts.
--with-liveWith --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.jsonlThe 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: --strict or --timeline with --mode, --md or --json with --format;
  • --strict with --timeline, and --md with --json;
  • both halves of an on/off pair: --cost with --no-cost, --redact with --no-redact, --keep-names with --redact-names;
  • --with-live without --from-ledger;
  • --work-branch when the effective mode is not timeline. The mode can come from --mode timeline, --timeline or the config file’s mode = "timeline". A work-branch set 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-run without --pr does nothing.
  • --ledger without --from-ledger does nothing.
  • --pr with JSON output (--json, --format json or format = "json" in the config file) prints the JSON and does not touch the PR. --pr only works with Markdown.
  • --no-cost affects the Markdown table only. JSON output always includes costs.
  • --keep-names and --redact-names do 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), using git 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/ or release/. So a session that started on main and then moved to feat/x counts entirely toward feat/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:

  1. 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;
  2. 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);
  3. a bold “Token total is at least …” line, when some calls were ambiguous;
  4. 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;
  5. 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;
  6. 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:

KeyHolds
branchThe branch reported on.
mode"claim", "strict" or "timeline".
redactedtrue when --redact was on. Absent otherwise.
totalCallsAPI calls in the report.
totalCostUSDThe estimated total cost in US dollars.
totalCostIsFloortrue when the cost is a lower bound, for example because a model is unpriced.
unpricedModelsModel ids that had no price.
priceTableasOf (the table’s date) and source: "bundled", "cache" or "override".
heuristicsCalls 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.
subagentsSubagent 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.
sessionsOne object per session: id, origin ("main" or the worktree), first and last timestamps, calls, tokens and agents.
modelsOne 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, … (main is kept);
  • subagent ids become a1, a2, …, and worktree names become worktree-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

MessageMeaning
warning: no price for <model> in the price table (as of <date>); the total is a floorA 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 skippedgit worktree list failed, so subagent calls in worktrees could not be recovered.
The ledger notesSee 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.

OptionDefaultDoes
[BRANCH]current branchThe branch to record.
--repo <PATH>.The repository.
--mode <MODE>claimAttribution mode: claim, strict or timeline.
--strictSame as --mode strict.
--timelineSame as --mode timeline.
--work-branch <REGEX>Timeline mode only: a regex naming the work branches, as for report.
--config <PATH>user config pathRead this config file instead of the default location. It must exist.
--ledger <PATH>.tally/ledger.jsonlThe 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_branch is the branch of the worktree directory named agent-<agent id> in git 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.
  • SessionEnd and SubagentStart payloads 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:

OSDirectory
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:

  1. .tally/prices.toml in the repository’s main checkout (the override, source: override);
  2. the per-user cache written by prices update (source: cache);
  3. 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:

KeyRequiredMeaning
as_ofyesThe date the prices were true, quoted: "YYYY-MM-DD".
[cache_write] five_minuteno, default 1.255-minute cache writes cost this multiple of the input rate.
[cache_write] one_hourno, default 2.01-hour cache writes cost this multiple of the input rate.
[cache_read] default_factorno, default 0.1Cache reads cost this fraction of the input rate.
[models."<id>"] inputyesInput price, dollars per million tokens.
[models."<id>"] outputyesOutput price, dollars per million tokens.
[models."<id>"] cache_readnoCache-read price for this model, dollars per million tokens.
[models."<id>"] cache_write_5mnoThis model’s multiplier, replacing five_minute.
[models."<id>"] cache_write_1hnoThis 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:

  1. a command-line flag;
  2. the config file;
  3. 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

OSPath
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.

KeyTypeDefaultFlagsMeaning
modestring: "claim", "strict" or "timeline""claim"--mode, --strict, --timelineHow calls are assigned to branches. See Attribution modes.
work-branchstring (a regex)not set--work-branchIn 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.
formatstring: "md" or "json""md"--format, --md, --jsonOutput format of report.
costbooleantrue--cost, --no-costShow the estimated-cost column in Markdown output.
redactbooleanfalse--redact, --no-redactHide identifiers in the report. See Redaction.
redact-namesbooleantrue--redact-names, --keep-namesWith 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.

ProblemMessage
Not valid TOMLconfig file <path> is not valid TOML: …
Unknown keyconfig file <path>: unknown key `foo` (expected mode, work-branch, format, cost, redact, redact-names)
Wrong valueconfig file <path>: key `format` must be "md" or "json" (and likewise for each key)
work-branch is not a valid regexconfig file <path>: key `work-branch` is not a valid regular expression
--config file missingconfig file <path> does not exist
File exists but can’t be readcannot 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.

ExampleShows
This repositoryAn 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:

  1. interviews the user about the issue, then cuts a short-lived feature branch from an up-to-date main, named per Conventional Branch (for example feat/issue-4-report-command), and runs the test suite for a baseline;
  2. dispatches specialized subagents, each into its own git worktree under .claude/worktrees/. The repository sets Claude Code’s worktree.baseRef to head, so each worktree starts from the orchestrator’s current commit;
  3. 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;
  4. merges each subagent’s branch into the feature branch with git merge --no-ff as it lands, and runs the test suite after every merge;
  5. 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 through git 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:

  1. checks the branch is not main, gh is signed in, the tree is clean and main has not moved;

  2. pushes the feature branch (only the feature branch, never a subagent’s);

  3. 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 -->
    
  4. runs the token report straight away, while every worktree still exists:

    just token-report --pr
    

    That recipe runs branch-tally report --pr with 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.