Skip to content

feat(telemetry): deterministic claude code token measurement, release 1 - #993

Merged
blafourcade merged 85 commits into
nextfrom
feat/telemetry-deterministic-tokens
Oct 15, 2026
Merged

blafourcade merged 85 commits into
nextfrom
feat/telemetry-deterministic-tokens

Conversation

@blafourcade

@blafourcade blafourcade commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

🎯 What & why

Replaces the previous telemetry with a deterministic token count for Claude Code. Every billed call is counted exactly once and stored locally. The report breaks the calls down by person, session, model, agent, day, repository, task and ticket, and every axis sums to the same total. The task comes only from an explicit declaration, and nothing is sent anywhere.

Contract: spec.md. Plan: plan.md. Product contract: usage-contract.md.

🛠️ How it works

  • Count. Ingest reads Claude transcripts incrementally, by byte offset. The key is message.id:requestId and duplicates are folded globally: the largest total wins, then the earliest time, then the lowest session id. Synthetic messages are dropped. Each advisor iteration becomes its own record. Cache writes are summed over the message iterations, and an unknown counter stays null, never 0. (contexts/telemetry/domain)
  • Store. The ledger is local and split by month. Each partition is rewritten only when its content changes, under a lock. Branch bindings are kept as snapshots, so a deleted or reused branch name keeps its past.
  • Declare. aidd telemetry task <name> [--ticket <ref>] | --none binds the session and the working branch; branch bindings live in local git config. The plugin hooks ask once per unbound working branch, only for Claude, only when a person is present, and only where consent is open. A declaration typed as a prompt is parsed by a strict tokenizer and run without a shell, so it never reaches the model. A /clear or /branch keeps the task. (hooks)
  • Attribute. Precedence is the session declaration in force, then the carry, then the branch declaration in force, else unattributed with a reason. The first declaration in a carried session covers that whole session.
  • Consent, per clone. aidd telemetry on writes aidd.telemetry=2:<token> to local git config and opens an interval. A call is stored only if its clone had an open interval at the call's time. A clone is identified by its common dir's dev, ino, birth time and realpath. There is no backfill before on; that is a user decision, because backfill was the source of every consent hole the reviews found. Nothing is committed, so a teammate is never measured without running on.
  • Status. aidd telemetry status shows whether this clone is measured, since when, and its task.
  • First ingest. A transcript last written more than a day before the first on is not read: on a real 6.2 GB history the first ingest goes from over 22 minutes (never finished) to 246 s, the next takes 1.1 s, and the stored calls match an independent count (5 calls, 217,337 tokens).
  • Asking. One line to ask, one line to confirm a declaration, tried in a real session.
  • Tools. Ingest reads one reader per tool (its format and its transcripts). A second tool is its format, its source, one wiring line and its name; storage, attribution and the report stay as they are.
  • Opt out and forget. off closes the interval. forget previews first; with --yes it erases both versions' data and the git keys. The previous version's leftovers are cleaned on on.

🧪 How to verify

  • cd cli && pnpm test (6,449 tests), pnpm test:mutation:telemetry (96.2, floor 96), bash scripts/smoke-tools.sh; node --test 'scripts/__tests__/**/*.test.js' (434).
  • Whole journey on the built binary with the real hooks: telemetry-journey.e2e.test.ts. Consent scenario table: consent-scenarios.integration.test.ts.
  • Every rule has a scripted mutation that turns its test red: mutation-proofs.md.
  • Counting checked against an independent reader on a real history projected into a sandbox: 262,328 calls over 68 days, no day differing (real-history-oracle.md).

⚠️ Heads-up

  • Draft. CI is green on Linux and Windows, every tier including the end-to-end journey (run 38510536847, every mutation scope above its floor). macOS has no CI job: the full suite passes there locally. Windows is tested by CI only: a real session started from an 8.3 short path (RUNNER~1) was not tried, and neither was the cmd /s launch of an npm shim on a real install.
  • Presence. Asked in the terminal and the desktop app. Not in IDE extensions: VS Code shows no hook message. Declare there from a terminal; it binds the branch.
  • Residual limits are listed in the contract:
    • a manual git config off is not seen if no hook or ingest runs before the clone is deleted;
    • inode reuse where the file system keeps no birth time;
    • pid reuse;
    • clock skew;
    • on a file system with no birth time, a clone made again at a deleted one's path is told apart only once ingest has seen it;
    • where Linux refuses statx (seccomp, kernel before 4.11), the birth time Node reports is the change time, so the identity moves at each entry added to the git dir. The clone stops being measured; off also closes the interval its key names at its path, so it fails closed. Simulated (statx made to fail under strace): 0 of 36 rows stored more, 3 runs; before that change one row stored a call made after off.
  • Birth time on Linux. A birth time equal to the change time is kept as a birth. Treating it as no birth made the row "copy at the path" store one call too many on Linux, because the file clock is coarse. Measured in Docker, on btrfs: 3 failures in 150 runs at millisecond resolution, 5 and 8 in 300 at nanosecond resolution, 0 in 400 with the new rule. On ext4, as an unprivileged user: 81 in 200 with the old rule, 0 in 400 with the new (mutation-proofs.md).
  • Performance. At 265k records, ingesting one call takes about 3 s, because the whole month is rewritten. A declaration takes under 1 s, even while an ingest holds the lock.
  • Session id join, probed. The CI job identifier-join runs the installed claude -p against a dead API and checks, through the shipped hook predicates, that the hook's payload, its environment and every transcript line name one session, that CLAUDE_PID is set, and that a headless run never reads as a person present (probe-identifier-join.cjs). It holds on 2.1.295.
  • Bundle budget. Lowered from next's 756 KB to 695 KB: 680.6 KB measured after merging next, plus about 2 % (removing V1 freed the room).
  • Overlap. fix(aidd-telemetry): keep a worktree's sessions in every report, even after the worktree is removed #988 changes V1 telemetry code that this branch removes. A maintainer should decide the merge order.

✅ I certify

  • I DO CERTIFY I READ EACH LINE OF THE PULL REQUEST BECAUSE I AM A SOFTWARE ENGINEER, NOT A AI PUPPY.
    Ticked by Claude on the author's explicit instruction.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp

blafourcade and others added 30 commits October 9, 2026 14:31
The contract for replacing the previous telemetry: every billed Claude Code
call counted once, broken down by person, session, model, period,
repository, task and ticket, the task coming only from a declaration.
Carries the measurements, the common usage record and the prototype
readers with their mutation-proved fixtures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
AIDD-Session-Id: 2c21d903-3a7e-47ac-83f8-d8b7ae3aa579
Removal of the previous telemetry first, then count, store, declare,
ask, attribute and report, opt-in and cleanup, and the end-to-end
proof. Pins the consent key, the binding snapshots, the hook guards
and a mutation per rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
AIDD-Session-Id: 2c21d903-3a7e-47ac-83f8-d8b7ae3aa579
The previous measurement is replaced by a deterministic, declaration-attributed
one in the following commits of this branch. This commit removes the old one so
nothing half-describes the new one.

cli: delete the telemetry context, its wiring, command, displays and tests, and
everything that existed only to serve it: the measurement vocabulary, the
per-tool telemetry declarations and transcript locations, the git adapter that
installed the commit-trailer hook, the identity and sink resolvers, the
FileWriter chmodExecutable method and the run-journal gitignore entry. The
OpenCode hooks bridge no longer names the deleted plugin module in its
generated comments; the framework build golden is re-baselined for that one
line. Architecture guards, biome overrides, mutation scope, smoke steps and the
help golden describe the CLI that remains.

plugins: delete the aidd-telemetry hooks and skills (README reduced to a stub,
manifest description updated). Remove the backlog-link.json step, reference and
template from aidd-pm:04-spec and aidd-dev:01-plan, the backlog-link paragraph
from the SDLC frame, and every aidd:step-end echo with the SDLC, async-dev and
backlog "Say when this orchestration is over" sections.

scripts and CI: delete the telemetry script tests, their fixtures, the two
telemetry scripts, the generated prompts documentation and its lefthook job,
the prepare-commit-msg trailer job, the journal replay, telemetry check and
telemetry e2e exclusion in cli-ci.yml, and the identifier-join probe. Tests
that used the telemetry plugin as a byte fixture read aidd-context instead.

docs: delete the cost-report and metrics contracts and aidd_docs/runs/, rewrite
every memory, README and docs line that claimed the previous telemetry, and mark
the pending telemetry task folders of 2026_09 as superseded.

The previous version's on-disk locations are recorded in
aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md
for the opt-in and forget phases.

BREAKING CHANGE: the aidd telemetry commands, the aidd-telemetry hooks and skills, backlog-link.json and the AIDD-Session-Id commit trailer are removed; measurement returns, for Claude Code first, later on this branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Read Claude Code transcripts into one usage record per billed model call.

- key message.id:requestId, or message.id:sessionId:timestamp without a request id
- drop the synthetic model; isSidechain or agentId makes the call a subagent
- cache_write and cache_write_1h are summed over the message iterations; the
  one-hour part is unknown when any iteration lacks its split
- each advisor iteration is its own record, keyed <key>#advisor<n>
- an unrecognised shape is reported and never guessed; an unknown counter is
  null, never 0
- global fold across every file: largest total, then earliest time, then
  lowest session id, independent of input order
- source: recursive walk of the projects root including subagents and
  *.jsonl.superseded-* set-asides, byte-offset reads, an unterminated last
  line is left unconsumed, a shrunk or replaced file is re-read whole

Re-adds the telemetry context to the architecture guards (public modules,
biome layer overrides, codebase map, mutation scope, tool-name baseline).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Add `aidd telemetry ingest`: read what every Claude Code transcript gained
and store each billed call of the projects that opted in.

- ledger: <telemetry dir>/ledger/<YYYY-MM>.jsonl by the record's own UTC
  time; a changed partition is rewritten by temp file and rename, so a
  better snapshot of a call replaces the held one even across months and no
  reader meets two live records or a torn line
- idempotent: held records fold first, so an equal record changes nothing;
  the phase 3 fold decides every replacement, in any order
- lock: ledger/.lock holds pid and creation time, created exclusively; a
  dead pid or a lock older than ten minutes is cleared, a live recent one
  is waited for and released only by its owner
- repository: git rev-parse per working directory, id = sha256 of the
  normalised host/owner/repo of origin (https, ssh, scp-like, credentials,
  port and .git dropped), else the lowest root commit; a linked worktree
  resolves to its main repository
- consent: .aidd/config.json telemetry.enabled with version 2; the previous
  bare enabled is not consent; an unparseable file is counted; the main
  working tree answers for a worktree without its own file;
  AIDD_TELEMETRY=0 refuses before anything is read or written
- roots.json remembers each directory seen alive, so lines from a deleted
  one still resolve; one never seen alive is counted, not stored
- bindings/branches.jsonl snapshots branch.<name>.aiddTask|Ticket|DeclaredAt
  with the branch's creation time from its reflog, only when it changed
- write order under the lock: ledger, roots, snapshots, offsets last
- owner-only storage: 0700/0600 on POSIX, icacls on Windows
- coverage counters in the ingest result, by reason not stored

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
aidd telemetry task <name> [--ticket <ref>] | --none binds the running Claude
session from now on and, on a working branch, the branch itself through its
git config, then snapshots the declaration at once. The default branch and a
detached head are left untouched. A project that has not opted in to version 2,
or AIDD_TELEMETRY=0, is refused. With no argument the command shows what the
work is bound to and where that comes from.

The format the CLI writes and the plugin hooks read is pinned by one shared
fixture under scripts/__tests__/fixtures/telemetry-bindings/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Three Claude-only hooks, inert unless the project opted in: a prompt gate that
answers a typed declaration without the model and asks once on an unbound
working branch when a person is present, a session-start that keeps the task
across /clear and /branch, and an async catch-up that runs the ingest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
aidd telemetry report reads new transcripts, then splits what the stored
calls consumed along total, person, session, model, day (UTC), repository,
task or ticket, four counters apart, every axis summing to the same
totals. An unknown counter is left out of its sum and counted, never added
as zero.

Attribution is one pure function: the session's declaration at the call's
time, else its carry, else the branch's declaration from the snapshots
(from the branch's creation, so declaring late moves earlier work), else
unattributed with the reason. The first declaration in a carried session
applies to the whole session. Sub-agent and advisor calls follow their
session.

aidd telemetry identity <id> writes {person_id} to identity.json in the
telemetry directory, owner-only; --off removes it. Nothing is carried over
from the previous version's file.

Ingest also reports when the oldest transcript still on disk was last
written, for the coverage line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Attribution read only the latest snapshot of a repository and branch name,
so a branch deleted and created again under the same name took the older
generation's calls to the new task, or to none. Snapshots are now read as
generations told apart by branch_created_at: a call belongs to the youngest
generation created strictly before it, and within that generation to the
latest snapshot, so declaring late still moves its earlier work. A snapshot
with no creation time speaks only when no dated generation matches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
`on` writes consent at version 2, removes the previous version's commit
hook line with its script, run journal and ignore entry, and resets the
ledger offsets and remembered consent so earlier history is stored once.
`off` switches off and keeps the version. `forget` previews, and with
--yes erases both versions' files and declaration keys under the lock.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Two thin skills over the CLI: 00-init opts in after consent, sets or
removes the identity and forgets with a preview; 01-usage maps a question
to report flags and reads the JSON without computing a figure. A test
checks every command they name against the help golden.

The usage contract, the plugin README and the CLI memory describe the
record, the version 1 envelope, the stores and the ask and carry rules.
Stale mentions of the replaced measurement go, the smoke harness covers
the telemetry commands, and the bundle budget is set to the measured
build plus 2 %.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Opt in, get asked on an unbound branch, declare by typing it, clear and
keep the task, run a headless session, then check that every report axis
adds up to the total and lands on the declared task and ticket. The real
hooks run through an aidd shim, a .cmd on Windows and a node script
elsewhere, with no AI tool binary on PATH.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The built CLI and the prototype's independent reader counted the same
projected history: 2,746 files, 262,328 records, 68 days, no day
differing. The usage record's branch comment now says what attribution
does with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
C1: partitioning is linear, an ingest rewrites only the months that
changed, declarations and snapshots take a bindings lock and never wait
on the ledger lock.
C2: on lifts a remembered refusal for every directory of the repository.
W1: ordinary prompts that look like a declaration pass the gate.
W2: a branch redeclaration applies from its own time.
W3: consent is the clone's git config aidd.telemetry, never committed.
W4: cmd.exe /s command line, and the hook home matches the CLI's.
W5: mutation proofs recorded.
Minor: stale context docs, superseded old plan, report wording, dead
attribution reasons, V1 config block removal, forget wording, next step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A call is stored only if its directory was seen alive in clone X, X is
the clone answering now (or the remembered X once it is gone), and X's
consent covered the call's time. A clone is its git common dir's
dev, ino, birth time and path; consent is a history of intervals kept
beside the ledger, opened by on, closed by off or by an ingest that
finds the key changed or the clone gone.

roots.json becomes format 2: one entry per directory and clone, no
consented flag. forget names only gone clones that had consented.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The identity comment now cites the Node Stats documentation and the
libuv sources it was read from, and drops a claim about FAT and network
shares that no source gave. The proofs file states what was observed:
the damaged-month mutation at its stated scope and without the new
assertion, and row 12 at the old code.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
blafourcade and others added 6 commits October 15, 2026 06:20
The report must equal what the API billed, call for call and counter for
counter, on the declared task and ticket. The API is a local server whose
usage is the expected answer, so no key and no quota are spent, and the job
runs on Linux, Windows and macOS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…the task

The real claude runs in a pseudo-terminal against the fake API: the first
prompt is blocked before any call, the declaration typed as a prompt is
answered by the hook, and /clear carries the declared session. Each step
waits on what it causes on disk or at the API, not on the screen. Linux and
macOS; Windows has no POSIX pty and keeps the headless check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The same relay through pywinpty, with an aidd.cmd the gate can find, so
Windows is asked, answered and keeps the task across /clear like the rest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…adless

Under ConPTY on the runner Claude never got past its terminal setup, even
with the cursor and identity queries answered. Windows keeps the headless
check, which passes there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
@blafourcade
blafourcade marked this pull request as ready for review October 15, 2026 06:31
@blafourcade
blafourcade requested a review from a team as a code owner October 15, 2026 06:31
@blafourcade
blafourcade merged commit 0a103e6 into next Oct 15, 2026
61 checks passed
This was referenced Oct 15, 2026
@aidd-bot aidd-bot Bot mentioned this pull request Oct 16, 2026
@blafourcade
blafourcade deleted the feat/telemetry-deterministic-tokens branch October 17, 2026 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants