Skip to content

feat(framework): measurement that works on five tools, each proven by a session that ran - #706

Open
blafourcade wants to merge 69 commits into
nextfrom
claude/aidd-telemetry-layer-e403uf
Open

feat(framework): measurement that works on five tools, each proven by a session that ran#706
blafourcade wants to merge 69 commits into
nextfrom
claude/aidd-telemetry-layer-e403uf

Conversation

@blafourcade

Copy link
Copy Markdown
Contributor

Measurement that works on five tools, each proven by a session that was actually run.

What a person gets

aidd setup --ai <tool>
aidd plugin install aidd-telemetry

Three skills, no CLI dependency, no endpoint, nothing leaving the machine: 00-init turns measurement on for a repository, 01-cost answers what a period or a task consumed, 02-check says whether the chain is actually recording.

The first citable figure this layer has produced, from a real three-skill chain:

sum of the four by_step rows   18 / 42 / 6,865 / 925,799 / 42,289
totals in the same JSON        18 / 42 / 6,865 / 925,799 / 42,289

Integer-exact on all five token fields. The fourth row is unattributed — the planning turn before the first skill call — and it stays its own row rather than being folded into the nearest step.

Per tool, without rounding up

Tool Journals Steps Figure
Claude Code yes stated by the tool yes
Codex yes yes yes
Copilot yes yes, on both payload shapes no — no per-request counter in its files
Cursor yes, interactive and headless yes no — writes no token count anywhere
OpenCode yes yes yes

Two tools give time and steps without an amount. That is a property of those tools, and the report names it beside the figure instead of printing a zero.

What was found by running rather than reading

Every one of these was invisible to a green test suite, and each was the same shape: something that looks installed, does nothing, and says nothing.

  • Cursor never loads a plugin's own hooks.json. Across three probes, headless and interactive, auto-discovered and explicitly loaded with a valid manifest, not one of seven declared events fired. The project's own .cursor/hooks.json fires normally — so the obstacle was never Cursor, it was where we installed. It also names its repository root workspace_roots where every other tool says cwd, and closes a turn on stop interactively and sessionEnd headlessly, disjointly.
  • OpenCode runs only a genuine ESM export. Three sessions were spent on CommonJS shapes that the loader found, logged, and never executed — no error, no output.
  • Codex will not run a hook nobody approved, and says nothing. Four consecutive sessions journalled nothing before the difference became visible.
  • Copilot's compat payload mixes two hosts' conventionstool_name: "skill" its own way, tool_input: {skill} Claude Code's way. Neither value followed from the other; guessing either would have failed silently.
  • A skill script that reached across hooks/ died at load on the one install shape that has no hooks/. 310 tests passed over it, because every one ran from the source tree.
  • A project set up by the CLI loaded no plugins headless (fix(cli): a project the CLI set up loads none of its plugins headless, and says nothing #703), and the first attempt at a real flow cost $1.27 and produced nothing while reporting success at every step.

Delivered, not only proven

Every mechanism above is delivered by an install, not by a file placed by hand — including the routes that were still skipping it: OpenCode's build and setup paths, and Cursor's marketplace route. aidd plugin remove unmerges what it merged.

Guards added, each proven by breaking it

  • Every skill script runs from a tree holding only what an install carries.
  • The CLI's journalAttributable derivation is pinned against the plugin's own declaration, per tool. It caught two real drifts.
  • Six parity guards over the declarations that exist in more than one copy, all mutation-tested.
  • pnpm test:changed runs only what a change can break: a CLI-only change went from 2m17s to 25s, without skipping anything reachable.

Known limits, written down

docs/telemetry-limits.md carries each with the measurement behind it. The three that will bite first: Codex needs an interactive approval once and a headless run never sees the prompt; OpenCode misses the first session of a server process, and a one-shot opencode run is always a first session; only Claude Code's writes name a task, so the others report by period and by step and not by ticket.

Closes

Closes #617
Closes #676
Closes #680
Closes #681
Closes #694
Closes #698
Closes #699
Closes #701

Opened by this work and left open: #700 (setup writes a model a ChatGPT account rejects), #702 (the tool declarations exist in several copies), #703 (a CLI-set-up project loads no plugins headless), #704 and #705 (per-project and per-day reporting).

🤖 Generated with Claude Code

https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp

@blafourcade

Copy link
Copy Markdown
Contributor Author

What to read, and why the file count lies

GitHub says 1801 files. 142 of them are this work.

The branch was cut on 16 July at chore: release main (#447). Since then main moved 115 commits and this branch moved 165, and in that window main received the CLI migration while this branch carries its own copy of it (10bdd605 fix(cli): migrate aidd-cli into framework as cli/). The three-dot diff a pull request shows is measured from the merge base, so it counts that migration twice — every file under cli/src, cli/tests, kanban and the other plugins is in the total for that reason and not because anything here touched them.

The eleven commits that are this work, from a0beac5b to 1015566f:

142 files changed, 10,518 insertions(+), 418 deletions(-)

  32  plugins/aidd-telemetry     the hooks, the three skills
  31  cli/tests                  and 29 cli/src
  28  aidd_docs/tasks            the plans, phases and measurements
  13  scripts/__tests__          the plugin's own suite
   3  docs                       what each tool can and cannot measure

Compare view for exactly those: 0509345...1015566

The base needs a decision before this leaves draft

Three ways out, none of which I will take unasked because they all rewrite or reshape someone else's branch:

  1. Rebase onto current main. The duplicated migration should collapse and the diff would show its true size. It rewrites 165 commits of history.
  2. Merge main in. No history rewritten, and it fixes the diff for the same reason — the merge base becomes main's tip. I dry-ran it: 90 conflicts, 63 of them add/add on the duplicated migration, 38 under cli/src and 20 under cli/tests. Resolvable, and not a thing to do quickly.
  3. Leave it and review by range, using the link above.

Everything else is ready

Eleven commits, each passing the pre-commit hook without modification. The pre-push run: 252 test files, 2680 tests. Plugin scripts 365, CLI unit 1931, integration 577, e2e 178, tsc clean, biome clean, no broken markdown links.

Every per-tool claim in the body rests on a session that was actually run, and the negative results are in aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/measurements.md in full — including the two probes that disagreed about OpenCode and the measurement that reconciled them.

@blafourcade
blafourcade changed the base branch from main to next August 22, 2026 09:44
@blafourcade

Copy link
Copy Markdown
Contributor Author

Retargeted to next, which is where it should have gone

CONTRIBUTING.md:53 says "Branch off next, target next". This was opened against main, which is the branch promote.yml rebase-merges next into — not one a feature branch belongs on. Fixed.

It also shrinks the count, because the merge base moves from 16 July to 13 August:

against main    1801 files   +164,300
against next     368 files    +45,625
this work        142 files    +10,518

Still not 142, and the reason is unchanged: the branch carries its own copy of the CLI migration that both main and next have since received, so the diff counts it twice. The comparison range in the previous comment is still the thing to read.

The base decision stands, and it is now smaller

Merging next in was dry-run against main and produced 90 conflicts. That number will differ against next and I have not re-measured it. The three options are the same — rebase, merge, or review by range — and none is mine to take on someone else's 165 commits.

Not merged, deliberately

The eight Closes fire on merge, so the backlog closes when this does. I have not merged it and will not: this is a shared repository with a release process (promote.yml rebase-merges next into main, back-merge.yml returns the other way), a draft pull request nobody has reviewed, and 165 commits of which 154 are not mine. Landing that on a shared branch is a call for whoever owns the release train, not for the author of the last eleven commits.

claude and others added 17 commits August 22, 2026 11:53
Ground the telemetry design on what the five host tools actually expose,
verified on their official docs.

- No hook on any tool carries tokens or cost, so the framework joins
  vendor OTel exports instead of collecting its own.
- Records the two join architectures (id mapping vs resource-attribute
  injection) and why they are complementary rather than exclusive.
- Splits task identity from the session ledger, keeping the one-writer
  per file property that makes merge conflicts impossible.
- Flags the contradiction inside #617 between its scope and its
  decisions, the undeclared #617 -> #620 ordering, and the ownership
  gaps on exporter config and sink.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uDp5FM1JJ6Yd2D9ZsfPH2
Second verification pass, primary sources only, per-cell [v]/[?] marks
and a Sources annex naming every page read.

Corrections to the first pass:
- "no hook carries tokens" was too absolute. Claude Code PostToolUse on
  a foreground Agent call does carry totalTokens and usage, documented
  as covering the final request only.
- Resource-attribute injection is verified, and splits in two: static
  keys land today through the settings env block, a per-session id still
  needs something that launches the tool.
- Codex span attributes reach spans only, not the events carrying tokens.
- Cardinality is now backed by vendor text and vendor code rather than
  asserted.

Codex is verified from its otel crate because its docs host is blocked,
and Cursor stays entirely unverified for the same reason. Both are
stated as access gaps, not as findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uDp5FM1JJ6Yd2D9ZsfPH2
Both vendors were unverified on the first pass because their doc hosts
were unreachable. Read on their official documentation now.

Cursor states outright that metric datapoints carry no correlation IDs,
and documents the workaround: sum the log-side token fields grouped by
conversation id. Tokens per session are reachable there, cost is not,
because cost is metric-only. Its export is a team-level Enterprise beta,
so the CLI can check it but never install it.

Codex confirms SessionEnd with its 1s/3s timeout and its subagent gap,
plus three lifecycle moments tool-paths.md does not list. Its
metrics_exporter defaults to statsig, so enabling telemetry without
setting that key ships metrics to a third party.

Consequences reworked: identifiers-off-metrics is now backed by three
vendors rather than asserted, cost per session is reachable on two tools
out of five, and coverage is stated as a hierarchy so status can report
it honestly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… execution

The load-bearing assumption is now measured rather than assumed. Two real
Claude Code sessions with OTLP captured locally: the session id a hook
sees is the same value the export carries, skill.name rides on both the
token and the cost counters, active_time is exported per session, and
query_source separates main from subagent work.

That shrinks what the framework has to build. On Claude Code the step
journal is already emitted by the tool, so the only thing left to supply
is the link to the work item.

The spec records the folder layout, the two new files, and the field
additions the existing templates need. Backlog artifacts already carry
type and status; delivery artifacts do not, which is why the kanban's
type filter returns nothing on this framework's own documents.

Two measured limits are recorded as such: skill.name is sticky, so it
over-attributes when skills interleave, and a Claude Code subagent has no
identifier of its own where a Cursor subagent does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass had metadata.json carry a type and an issue reference.
Both already live on the backlog artifact, in type, work_kind and
source, so the spec was creating a second truth that would drift.
metadata.json now holds one upward link and nothing more.

Records the full chain from a run to its epic, and how each kind of work
attaches. The existing relation model already covers every case: a bug
fix is a Task whose parent is the Defect, a defect names the artifacts it
broke through related_to, a spike names the artifacts it blocks. Nothing
new was needed beyond task_id and backlog.

States that nothing may point downward, so no one adds an inverse link
later: readers index the delivery folders and group by backlog, which is
what the relation reference already prescribes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing quota

The identifiers are minted client-side, before any model call, so a
provider pointed at an address that answers nothing still starts a
session, fires its hooks and emits its telemetry. Codex and Copilot were
therefore verified on zero tokens and zero credits.

Codex: hook session_id equals conversation.id on codex.sse_event.
Copilot: hook sessionId equals gen_ai.conversation.id on the invoke_agent
span. Cursor stays open, since its export is a team-level Enterprise
setting and there is nothing to compare against without such an account.

Two locks found by running the probes rather than by reading docs, both
of which leave a hook installed and silent: Codex needs the hooks feature
flag and persisted hook trust, and Copilot ignores repository-level hook
files in a directory that was never trusted while honouring the same
content at user scope. These are the states #617's status must report as
broken rather than healthy.

Because the probe is free on two tools, it belongs in continuous
integration rather than in a one-off check: the equality is proven by a
session, not by construction, and a tool update can break it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe was written and run. Cursor rejects a bogus key before opening
a session, so no hook fires and the free-verification trick that works on
Codex and Copilot does not apply. There is no login on this machine
either, so nothing could be observed.

More consequential than the missing measurement: the hooks documentation
describes editor moments only, down to workspaceOpen, and nowhere states
that the cursor-agent binary reads .cursor/hooks.json. Since a CLI
install is the only mode the AIDD CLI has, Cursor coverage may have to be
withdrawn rather than confirmed.

Both documents now say so, and name what would unblock it, cheapest
first: a login to learn whether the binary honours hooks at all, then an
Enterprise account with team export to close the id equality.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scope question is settled, and favourably. cursor-agent does read
.cursor/hooks.json, so Cursor can sit in a layer installed by a CLI. The
documentation describes editor moments only, down to workspaceOpen, which
made the doubt reasonable; a real session settles it.

Its payload carries session_id, conversation_id and generation_id where
the documentation describes one. They hold the same value on a
single-turn session, which is a trap rather than a reassurance: the
ledger must store conversation_id, the only one documented as stable
across turns. A two-turn probe would say whether the others drift.

Getting there needed three refusals: Cursor validates the API key, then
the model name, then workspace trust, all before opening a session. So
the free-verification trick does not apply and checking Cursor costs a
real turn.

Which turns the earlier finding into a pattern: not one probe worked on
the first try, and always for the same reason. Writing the hook file is
not enough, a lock has to be lifted too, and each tool locks differently.
A hook installed without lifting it is silent and raises nothing, which
is the worst state a measurement layer can be in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checked again after the upstream request for OpenTelemetry support was
raised. Three findings, one of which corrects an earlier source.

The binary does carry @opentelemetry/api and @opentelemetry/sdk-trace,
along with the standard OTEL_* variable names. It carries no exporter
package, and a session that completed successfully with
OTEL_EXPORTER_OTLP_ENDPOINT set produced no OTLP request at all. The
strings arrive as a transitive dependency, so their presence is not
evidence of support - worth stating, because a table filled from a string
search would have recorded the opposite.

The upstream issue exists, is assigned, and has neither comment nor
linked pull request.

The repository also moved from sst/opencode to anomalyco/opencode. The
first pass cited the old one: same conclusion, wrong source, now fixed in
the annex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wrong twice, and worth recording why. The switch is a config key rather
than an environment variable, experimental.openTelemetry in
opencode.json, described by its own schema as "Enable OpenTelemetry spans
for AI SDK calls". And the second run that seemed to confirm the absence
was void: the collector had failed to bind its port, so it recorded
nothing whatever the tool did.

With the flag set, a real session exports OTLP. Its ai.streamText spans
carry gen_ai.usage.input_tokens, gen_ai.usage.output_tokens and a
detailed ai.usage breakdown, with session.id on the same span, so tokens
per session are reachable. No cost is exported, so a price table is
needed as on Codex. It also honours OTEL_RESOURCE_ATTRIBUTES, which makes
the injection path available.

Two reservations, both measured: the public documentation still says
nothing and the upstream request has no reply, so the surface can move
without notice; and one trivial session produced 495 spans across 348 KB,
because everything down to file reads is instrumented. Sampling would not
be optional.

The reporter now checks the collector is listening before drawing any
conclusion, and says a silent result is void rather than a finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The measurement campaign moved the risk, so the milestone plan has to
move with it. The id join was the unknown the milestone was built around;
it is now proven on four tools, two of them without spending quota.

What replaces it is sharper: no probe worked on the first attempt, and
never for a different reason. Codex needs a feature flag and persisted
trust, Copilot ignores repository hooks in an untrusted folder, Cursor
wants --trust. A hook installed without lifting its lock is silent and
raises nothing, which is worse than having none, because it produces
numbers that look right.

So v1 proves the pipe flows before it proves what it carries: correct the
per-tool facts, write the run journal, ship a status command that checks
a hook actually fired rather than that a file exists, and read one number
per task and per step. Claude Code only.

Out of v1, with reasons: the commit trailer, the four other tools, the
YAML config root, the backlog links, and both consumers. The plan also
records not waiting on #585, since the CLI has no YAML parser and the one
key it needs fits the JSON it already reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not mirroring

The question has an answer already written in the framework's own
persistence reference: never mirror one Story across supports. An
artifact lives on exactly one support, so there is nothing to sync and no
divergence to manage - the problem is removed rather than solved.

The spec now says what backlog points at: an issue reference when the
backlog lives with the ticket provider, a project-relative path when it
lives in Markdown, which is what persistence.md already prescribes. The
earlier draft assumed a Markdown backlog and hardcoded a path.

It also records why the delivery folder and the run journal never compete
with the remote: no ticket provider expresses which folder delivered
which issue, which steps ran, or how long the sessions took. They add,
they do not copy.

This repository is the illustration. It has no aidd_docs/backlog, its
GitHub issues are its backlog, and creating Markdown stories for the same
subjects would break the rule and manufacture the drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on a real session with the AIDD plugins installed from their
marketplace: skill.name reads "third-party" on both the token and the
cost counters, and OTEL_LOG_TOOL_DETAILS does not lift it. The flag only
un-redacts the skill_activated event, which then carries the real
aidd-context:11-explore.

The docs said so for anyone reading to the end - third-party plugin skill
names are replaced - and AIDD ships from a third-party marketplace. The
earlier probe missed it by testing a project-local skill, which the same
rule exempts. Presence was measured, value was not.

Three consequences. Claude Code stops being the exception: metric-grain
joining holds for per-session totals only, and the per-step breakdown
joins on logs like the other three tools, so the pipeline must ingest
events from v1 rather than later. The breakdown becomes a correlation of
skill_activated with api_request rather than a filter on an attribute.
And a hard privacy trade appears, since the same flag also logs Bash
commands and tool inputs, which makes collector-side attribute filtering
a requirement rather than a convenience.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An earlier version of this plan described the owning plugin, the way a
task resolves, the CLI surface and the join. All four were falsified by
measurement within two days, and all four stayed readable as
instructions while the issues said the opposite. The epic cites this
directory as the plan of record, so anyone starting from it would have
built the wrong thing.

Phase files are removed rather than corrected: their content now lives in
the issues, and a plan that restates its issues drifts from them
silently. What remains is the ordering, the parallelism, and the three
decisions that belong to no single issue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… is in scope

Two contradictions the spec carried against itself.

It said per-step cost could not come from the metrics, then, further down,
that on Claude Code the join was direct and only needed filtering an
attribute. An implementer reads the second and produces a report where
every AIDD step reads third-party.

Replaced with what was measured. api_request carries prompt.id,
event.sequence, tokens, cost and model, and its own skill.name is
redacted like the metrics. skill_activated carries the real name with the
same correlation keys. So the rule is: order by event.sequence within a
session and carry the last activated skill forward. That is exact rather
than a time window, and it mirrors the provider's sticky behaviour
instead of fighting it.

The collector also stopped being a non-goal. Claude Code exposes no file
exporter, so without a receiving endpoint nothing is readable once the
session ends, and every reading issue depended on a component the design
had excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of #620's premises were unproven. Probed both before writing the plan.

Installing the plugin does activate its hooks: no plugin.json in this
repository declares a hooks key, so the mechanism might have reached users
only through `aidd framework build`, which would make "do not install it"
the wrong opt-out. It is discovered by convention; the premise holds. The
same probe showed the hook fires on a session that ends "Not logged in", so
verifying the journal costs nothing.

Host detection cannot use field names: Claude Code and Codex hand a
SessionStart hook the same five keys. It must not use environment either,
since a Codex session launched from a Claude Code session inherits
CLAUDECODE and CLAUDE_CODE_SESSION_ID from its parent, and nesting is the
normal case here. The discriminator is the shape of transcript_path, with
an unrecognised host degrading to writing nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ok field

The journal's only consumer joins against telemetry, so the field name it
carries has to be the one the export uses. The body's example already said
session.id while the spec's prose described the hook-side name; they are
different strings and one of them is unusable. The hook-side name needs no
storage anyway, having already given its value in vendor_id.

Three checks on the run-journal plan, recorded with it: the Codex path shape
holds under a default ~/.codex and was not an artefact of the probe home;
gitignoring .aidd/ does not re-open the coverage failure #620 flagged, since
the pointer is ephemeral by design and the skills rewrite it; and the opt-in
directory is not the destination, so status has to report "on, not yet
materialised" rather than either "on" or "not wired".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Test and others added 29 commits August 22, 2026 11:54
Two records one sequence apart share a millisecond in a captured export:

    seq=45  api_request         ts=2026-08-18T17:04:39.258Z
    seq=46  assistant_response  ts=2026-08-18T17:04:39.258Z

So a timestamp alone cannot order a session, and `event.sequence` was
being discarded on arrival because it was not on the allowlist - a value
that cannot be recovered later, since the payload is not kept.

Three lines: the field, its allowlist entry beside the timestamp, and its
place among the numeric fields. Nothing else about the stored shape
changes; the reader that consumes the order is #629.

The test asserts the collision from the raw capture rather than from a
hand-written record, and asserts the stored sequences sort into the
export's true order. It cannot assert the collision on stored lines: seq
46 carries no `cost_usd`, so the mapper drops it as not billed, by
design.

Refs #663
…tools

Two things that had to land together, because the second has nowhere to
write without the first.

**The journal stops being single-host.** `journal.js` compared against the
literal `"claude-code"`, so Codex, Cursor and Copilot wrote nothing at
all - a step boundary had no run file to append to on three hosts out of
four. Each host now declares how its session id is read (Copilot alone
spells it `sessionId`) and how its working directory is read (Cursor
delivers no `cwd` at all, only `workspace_roots`, of which the first
entry that is genuinely a git repository is taken - a multi-root
workspace lists several and not all are repositories).

**A started step is recorded as a fact.** The already-declared
`PostToolUse` is read twice, by two guard chains that share nothing else:
`handleFileWritten` returns early unless the path looks like a task
folder, and a skill call has no task path. One table per host, two
extractor families - the name read from an argument on Claude Code and
Copilot, derived from a `SKILL.md` path on Codex and Cursor.

The path family scans every string in the payload rather than reading a
named field on a named tool. That is not defensive coding: the Codex
capture names the tool `Bash` where its own transcripts record
`exec_command`, so an extractor keyed on the tool name would have matched
nothing, silently, with every test still green.

No end, no duration, no parent is written. A probe on each of the five
tools established that not one exposes when a skill's work finishes; an
end would be a conclusion stored as a fact, and the journal's shape
exists to prevent exactly that. A step is a half-open interval, closed by
the next marker or by the turn, and that derivation belongs to the
reader. An emitted end marker was rejected because it would depend on the
model choosing to emit it.

The argv word `file-written` becomes `tool-used`, since the event now has
two readings. A new test reads `hooks.json` and asserts every word it
ships is one `journal.js` recognises - the journal was already dead on
every real installation once, for a mismatch of exactly that shape.

Two hosts stay silent for reasons outside this change, asserted as
current behaviour rather than papered over: Copilot's payload shape
defeats host detection (#681), and Cursor fires no turn-end headless
(#680).

Fixtures are captures from real sessions on all four hosts, redacted; a
directory-scanning test fails on any leak, including in the four that
predate it.

Refs #663
Kept because the reasoning is the deliverable's other half: which tool
exposes what, why a step is a half-open interval, and why an emitted end
marker was rejected. The measurements behind them live on the ticket.

Refs #663
…rote

Decided in #684: the receiver stops being on the critical path. A project
could be switched on, export correctly, and store nothing because nobody
was listening, and nothing made that visible. The tools already write
their own counters to their own files; this reads those.

Claude Code's transcript, Codex's rollouts and OpenCode's export all
yield token counts and a model. None yields a dollar amount, so nothing
here computes one — #654 owns turning counters into money, and mixing the
two would hide which half was measured.

**Provenance is a required field, and the schema goes to 2.** A figure
read locally and a figure received from an export are not interchangeable.
Making it optional would have meant a default meaning "the old route",
which is exactly the unreadable field this avoids. No migration is
written: the sink is delivered but unmerged, so this is the one moment
where bumping costs nothing. A reader cannot stamp its own provenance —
`LocalCostCandidateRecord` omits the field, so it structurally cannot
claim to be an export it is not.

**OpenCode is asked, not queried.** Its counters live in SQLite, which
would have meant a native dependency — a prebuild per platform and ABI,
otherwise compiling at install time, so one tool's feature would break
`npm i -g` for every user including the four fifths who never touch
OpenCode. `opencode export --sanitize` returns the same figures on
stdout. `package.json` is untouched.

Four things measured against real files that a plausible implementation
would have got wrong, silently:

- **One Claude Code API call can log several transcript lines.** A
  `thinking` block and its `tool_use` share one `requestId`, one
  `message.id` and one identical `usage`. Four requestIds out of five in
  the sampled transcript carry more than one line, and one carries three.
  Mapping per line inflates every figure two- to threefold, and a
  synthetic one-line-per-call fixture passes.
- **Codex's `input_tokens` is inclusive of `cached_input_tokens`**, where
  Claude Code's is exclusive. Confirmed by arithmetic on four consecutive
  events: `total_tokens` equals input plus output, not input plus cache
  plus output. Without the subtraction the same column means two
  different things depending on the tool.
- **Codex's `token_count` event carries no model and no request id.**
  Those live on the `turn_context` that precedes the run, so records are
  emitted per turn, not per event.
- **A rollout's `session_meta` carries both `id` and `session_id`, and
  the hook sees `id`.** On a fresh session they agree; on a resumed one
  `session_id` holds the parent thread. 124 of the 330 rollouts on the
  measuring machine are resumed, so this is not a corner case — and a
  reader keyed on the wrong one passes every test written against a
  fresh session.

A tool that cannot be read says so, with its reason, and a covered tool
carries any caveat on its own figures as data rather than a comment: a
comment reaches nobody downstream. Copilot writes one counter per turn
and nothing else; Cursor writes none at all and its export is an
enterprise setting nobody here can enable. Both are measured facts, not
gaps waiting to be filled.

Refs #685, #684
Carries the reasoning the code cannot: why the receiver stops being on
the critical path, why OpenCode is asked rather than queried, and why
each tool is read at the granularity its own file offers rather than at
one imposed on all three.

Refs #685
A consumer had one clue and it was the wrong one. `vendor_field` reads
`sessionId` for a locally-read Claude Code record, `session.id` for the
same one exported, and `session_meta.id` for Codex — it encodes the route
as much as the tool. Anyone aggregating across tools would have had to
reverse that, and would have got it right until a sixth tool arrived.

The answer already existed at both ends and was thrown away. The export
mapper resolves which declared tool a payload belongs to, then keeps only
the name of the attribute that matched. The local-read use-case asks a
specific tool's reader and forgets which one it asked. Both now carry it
through, and neither names a tool: the export path takes it from the
declaration it matched, the read path from the entry it looked up.

A reader still cannot name its own tool. `LocalCostCandidateRecord` omits
it alongside `sink_schema_version` and `provenance` — a reader that could
name itself could name another.

The version stays at 2 although a required field is added. A version
number tells a consumer what to expect from a line they hold, and nobody
holds a version 2 line: the sink is delivered and unmerged. Bumping for a
shape that exists only on this branch would encode its history into a
wire format. The bump belongs to the first release.

Two proofs worth keeping. The anti-literal test iterates `AI_TOOL_IDS`
rather than listing the names to forbid, so a sixth tool is covered the
day it is declared and not the day someone remembers. And the reader's
inability to name itself is asserted at compile time — removing `tool`
from the omitted set fails the build in four places, the assertion plus
all three readers, which are then required to supply one.

Verified against real data: reading this repository's own session stored
5247 records, every one naming its tool.

Refs #687
Carries what the code cannot: why the tool is a field rather than an
inference, why an absent step reads as unattributed and never as outside
any step, and the measurement behind that — Claude Code omits its own
attribution field both when no skill ran and when the version predates
it, with no null to tell the two apart.

Refs #687
Two sources, and they are not the same kind of claim.

Claude Code writes the skill into its own transcript, per assistant
message, on the line that already carries the counters — measured across
40 transcripts, 2267 attributed messages, 25 distinct skills. Real names,
no flag, no journal, no interval. Where a tool states the step itself,
that is what is stored.

Everywhere else the run journal's boundaries apply: the export path,
where the vendor's own attribute reads `third-party` for every framework
skill, and Codex, Cursor and Copilot, none of which was measured to have
an equivalent. A step covers the half-open interval from its own moment
to the next boundary, and a record whose moment falls inside belongs to
it.

So every record says which answered: stated by the tool, derived from an
interval, or unattributed. Collapsing the first two would let a consumer
read an inference as a measurement, which is the failure this layer
exists to prevent.

**An absent step reads as unattributed and never as "outside any step".**
Claude Code omits the field both when no skill ran and when the version
predates it — it arrived around 2.1.220 — and across twelve versions
there is not one null to tell the two apart. A report may say a figure is
unattributed. It may not say the work happened outside a skill.

The journal is not a requirement. A session without one yields the same
counters, all unattributed, and a missing or truncated journal costs
attribution rather than figures.

Three things found while building this, none of them in the brief:

- A journal boundary with an unparseable moment used to let the previous
  step's interval swallow everything after it — silent attribution to the
  wrong skill. Unparseable boundaries are dropped before pairing.
- The export path is deliberately not journal-attributed. The mapper runs
  at receive time, while the journal is still being written: the live
  turn has no end yet, so its last boundary would read as an open
  interval and claim whatever arrived next. Inferring once, from an
  incomplete file, into a stored record is worse than leaving it
  unattributed.
- A Codex record carried no moment at all, so no interval could ever
  reach it — the journal is its only step source. The rollout carries one
  on `turn_context`; it is now taken. Deliberately the turn's start and
  not a moment from a counted event inside it, since the record covers
  the whole turn.

Refs #687
Everything the layer stores was described only by a TypeScript interface,
which a pricing service or an aggregator cannot import. This is the shape
in a form they can implement against: every field with its meaning, its
presence condition, and what its absence means — an absent counter and a
stored zero being different facts, evidenced by Codex omitting
`cache_write_input_tokens` rather than sending it as zero.

Four things a consumer cannot discover and would get wrong, each stated
with the numbers rather than a sentence:

- **The two kinds overlap.** `request` records carry per-request figures;
  `session` records carry ten-second deltas of the same quantities. On one
  captured session the request lines total $0.1605 and the session lines
  $0.0151 — a whole session against one flush, not a contradiction. Cost
  and the four counters come from `request` only; `active_time_s` from
  `session` only, since no log record carries it.
- **`session` is one line per datapoint, never merged.** Six for one
  session. A consumer expecting one reads a fifth of the truth.
- **A re-read appends unless matched on `turn_id`.** Local reading
  re-reads a growing file by design. The worked example shows 30 stored
  rather than 60, and the counterfactual without a `turn_id` that does
  reach 60.
- **`unattributed` is not "outside any step"**, in the same words the
  records use, because that is where a consumer would look for permission
  to collapse them.

The coverage table keeps each tool's own declared vocabulary rather than
flattening it: measured, unmeasured and unsupported mean different things,
and Cursor is unmeasured on export while unsupported on local read for
two unrelated reasons.

The document cannot drift. A test parses the field names from the
interface and from the document's own headings — neither list is
hand-copied — and fails in both directions naming the offending field.
The worked examples' totals are recomputed from their samples, because an
example that no longer adds up is how a reader stops trusting the rest.

One claim was flagged as unsourced by its author and is now sourced: the
Copilot `cost` field is denominated in premium requests rather than
currency. Across fourteen local sessions it reads 0.33 for every
single-request `claude-haiku-4.5` session while consumption ranges from
2.04 to 2.95 billion nano-AIU, and 0 for a five-request `gpt-5-mini` one.
It tracks request count times a per-model multiplier, invariant to what
was consumed.

Refs #687
Something reads what #687 wrote down. `aidd telemetry report` answers what a
period, or one task inside it, consumed - broken down by step, model and tool,
with every attribution's strength printed as a number rather than gestured at
in a caveat.

Three deliverables interleave in these files and are committed together because
they touch the same lines: the reporter itself, one tool's reader no longer
failing every other tool's read, and one object a program can consume.

A period means when the work ran. A session read locally days later is appended
to today's day file while its records carry their own, older moments - measured
on a real Codex rollout, two records stamped 2026-07-29 living in
2026-08-21.jsonl. Selecting by day file would have put July's work in August's
total and looked right doing it. Every route now carries a moment taken from
what it already writes, and a record with none belongs to no period rather than
being placed by the day we heard about it.

Determinism is asserted against record order, not only against repetition. A
re-read appends, so the sink's line order genuinely differs between machines,
and that check found `attributionMix` carrying insertion order. All three
strengths now emit every time, in a fixed order, zero where zero is what was
measured.

What a tool can supply is declared per route rather than discovered from a
missing number - Claude Code carries an amount on its export and not on its
local read, and states its own step on the local read and not on the export.
Each declaration is checked against what its reader actually produces from a
captured file, and a declared route with no capture may claim nothing. That
check found a real error on its first run.

Closes #629, #689, #690.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Installing the plugin is the whole installation. Two self-contained scripts,
each inside the skill that owns it, running under plain node with nothing else
on the machine.

  skills/00-init/scripts/telemetry-switch.js     1.6 KB   on | off
  skills/01-cost/scripts/telemetry-report.js   102.3 KB   read | report

CommonJS `.js`, matching the hooks beside them: the plugin directory carries no
package.json, so one module system across every executable file it ships.

Where they live was not a preference. A top-level `bin/` is dropped - installing
a plugin translates its files into each tool's own layout, and that translation
carries skills, agents, commands, rules and hooks and nothing else. A script
anywhere else is silently never installed.

Moving them was not enough either. Every installed file went through
`rewriteContent`, which edits paths inside prose: measured, it changed the
bundle by six bytes on Codex and one on Copilot - a script that no longer
parses, shipped without a word. The translator now separates prose from
artefact, on the native path and the flat one. The hook scripts had been
escaping that by luck rather than by rule.

Two guards keep two builds one contract: the plugin's script and the CLI answer
byte-identical JSON for the same inputs, and the committed bundles are rebuilt
and compared so a source change without a rebuild fails.

The whole life of measurement is exercised in one sequence rather than in a test
per step - reporting before enabling answers nothing rather than failing,
disabling stops the recording without erasing what was measured, and re-enabling
resumes into the journal that already exists.

Closes #691.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A live headless session exposed the gap: asked to create a file, the model
reached for Bash and wrote it with a shell redirect. That payload carries a
command string and no path, so nothing was recorded and the session belonged to
no task - while the tokens it consumed were measured in full.

No fixture could have shown it. Every captured payload uses a file tool, so
every test asserted the path that works.

The command is still never read. A command mentioning a path is not a command
that wrote it, and attributing on a mention would invent a task. What changed is
that the hook observes instead: at the end of every turn it walks the task tree
and records what changed, marked `source: "observed"` beside the exact
`source: "tool-stated"` a payload gives.

At turn end, not at every tool call. A turn ends once per prompt while tools
fire dozens of times, so this costs one walk per turn - and it keeps a guarantee
a test already pinned, which caught a first attempt that ran on every
PostToolUse: a tool call that wrote nothing still shells out to git zero times.

The recorded moment is the end of the turn rather than the write. Nothing here
observed when a file changed, only that it had, and a task is derived from the
path.

It also stops task attribution being Claude Code's alone: the pass reads the
repository rather than a payload, so it runs identically on every host the
journal covers - Codex included, whose apply_patch writes were the other half of
this gap.

Closes #692.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…goes next

The published FAQ said the framework collects nothing while it collects. That
sentence is the one people quote when asking whether AIDD watches them, and
leaving it while shipping the opposite is worse than never having written it.

Measurement now has its own section rather than a cramped bullet: off unless you
turn it on, what is recorded and where, what is never recorded, that nothing
leaves the machine today, and that turning it off keeps what was already
measured. The remaining "no hosted service" claim is still true and stays.

The plugin's own README stopped describing a plugin that ships hooks only and
does nothing yet. It now says what a person gets, in the words they would use to
explain it, with a coverage table naming what each tool cannot do and the ticket
that would close it.

`docs/telemetry-limits.md` collects every limit with the measurement behind it,
so a missing figure is explained rather than rediscovered.

And a plan to a clean v1 in four milestones, each worth stopping at: what exists
reaches someone, every declared tool records, it cannot lie quietly, the figures
leave the machine. Merging comes before any new work - nine tickets on one
branch is the largest risk in it, and it grows every hour.

Closes #658.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A minified blob in someone else's repository is a reason not to trust it, before
it is anything else. Both scripts were unreadable; neither had to be.

The switch is now hand-written plain CommonJS, like the hooks beside it. It is
the file someone reads before allowing anything to be recorded, and sixty
commented lines answer "what does `on` do" better than any build artefact could.
Nothing generates it, so nothing can drift from it.

The reporter stays generated - it bundles the whole domain and could not be
hand-written - but no longer minified. Unminified it keeps real function names
and a `// src/...` marker above every block, so a reader sees what it does and
where each part came from. A header at the top says what it is, what generates
it, and what fails if it goes stale. It costs forty percent in size, on a file
that is copied rather than downloaded, and buys an auditable one.

Both properties are pinned: the bundle must still carry its source markers and
its real names, and the switch must stay short enough to read in full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…it is not

Milestone 1 begins with the host that records nothing at all.

#681 read the breakage out of a bundle two minor versions behind what is
installed, and every link in it is sound and untested against a payload. So the
plan starts by watching rather than fixing: a hook that writes down what it is
given, one session, a fixture. A detector fixed against a shape nobody has seen
would be guessing with extra steps, and a wrong guess there is silent - the
journal simply stays empty, with no error and no line.

Two things this machine already settles for free. Another tool registers Copilot
hooks in PascalCase and receives payloads, so PascalCase hooks do fire on 1.0.80
- the open question is only which key set arrives. And its own hooks file mixes
both spellings, so both payload builders are reachable and handling one is not
enough.

Also filed, and not planned here: the reporter the plugin ships is 144 KB
because reading a session asks for a tool's whole definition. Marketplace entry
shapes are sixteen percent of a script that reads a transcript, and over half of
it is machinery for installing plugins. It is not a copy of the CLI by design;
it is one by accident, through a dependency edge nobody meant to draw. #696.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The reporter was a 4,183-line bundle. It is now 1,281 lines of plain CommonJS
across seven files, each doing one thing, readable by whoever installs the
plugin into their repository.

The size was never the code. Reading a session asked for a tool's whole
definition, so marketplace entry shapes were sixteen percent of a script that
reads a transcript and over half of it was machinery for installing plugins. A
table of five tools replaces the registry; a file replaces a port and its
adapter.

Two implementations of one contract is the risk this takes on, and the answer is
to check it rather than to hope. Both are run against the same files and
compared byte for byte - the printed answer, the records that land on disk, the
JSON, and the error messages, since two tools that disagree about why something
failed are two tools nobody can reason about together.

That check found two real divergences before it was even a test: `agent_name`,
which the transcript spells `attributionAgent` and only on a sidechain, and
`effort`, which the reader dropped entirely. A third was key order, which
changes no meaning and does change bytes - the counters are now added in one
order everywhere, so equivalence is assertable rather than approximate.

The measured facts kept their comments: input inclusive of cache on Codex and
exclusive on Claude Code, increments rather than running totals, deduplication
on a request id, and an absent skill meaning nothing rather than none. Nothing
else has one.

Fifty-eight tests beside the scripts, named for the behaviour they hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…ild strategy

Each tool that runs hooks declares which root variable it expands, so any install
route can read it from one place. The build route now reads the declaration rather
than keeping its own copy, eliminating the point where the two spellings can drift.
A tool that runs no hooks declares none, making absence a statement rather than
a default value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
… says why

Hook support no longer defaults to false; every tool now declares what is true of
it. Tools that run hooks receive them with commands naming their own declared
variable. When a tool cannot host plugins or has no hook support, the reason is
stated alongside the falsehood, not inherited from a silent default. A hook is
checked for resolving on arrival, not merely for installing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…ends

The journal reads a payload shape that differed from what a bundled version
showed, and that gap kept the chain from running. We now capture Copilot's real
payload as fixtures, holding its key set exactly as it arrived. The host
recognition logic extends to the actual shape, with a test that fails if the shape
regresses. An unrecognised payload is now distinguishable from no payload at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…rding

A new skill diagnostic answers four questions independently: is the hook
registered, is the session journalled, are the tool's files readable, and do the
journal and files join. Each question has its own check so failures name
themselves. The chain stops failing silently and starts naming the way it breaks,
every time. Tests prove the checks detect their specific failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…t it dropped

The turn-end walk processes the task tree, capped at 2000 entries based on timing
measured on a real repository. When the cap is reached, the report now says what
was dropped instead of silently truncating. A period holding a hundred journalled
sessions answers fully, with timings written down so the next person can compare
against them rather than guessing at scale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
OpenCode now journals its own session id. Codex says when it is holding a hook
back. Copilot's step boundaries are recognised from its own payload. Cursor is
known not to run plugin hooks. Every claim about a tool comes from that tool
running, never from reading its bundle, and what it cannot measure is stated as
itself rather than guessed. A script that a hook loads now resolves to the
installed plugin on every tool it was delivered to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Architecture and telemetry limits are documented for each of five tools: Claude
Code measures everything and writes it out; Codex runs hooks but they may be
skipped in silence; Copilot's compat payload is now recognised and its steps
recorded; Cursor is known not to run plugin hooks; OpenCode journals its own
sessions but misses nothing else. Each tool's facts come from sessions that ran,
and every limitation is written down so a consumer can act on it instead of
hoping for a number that cannot come.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
A script identifies which tests a working-tree change can break, running only
those instead of the full suite. This keeps feedback loops tight during telemetry
work, where the change footprint is large but the affected tests are concentrated.
Tests run in seconds, not minutes, so a developer can verify a change before
committing rather than waiting for a full suite to finish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The journal resolves project_id and project_remote for the repository a session ran in. Carry that fact forward to the stored record instead of losing it - a machine-level sink can then separate costs by project.

Phase 1 of measurement breakdown: the session-project record type lives in the CLI domain, the journal surfaces what session_start already holds, and the report script threads it through to each record. By-project reporting comes next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The envelope bumps to version 2. Add two new groupings at the top level: every day the period spans (including gaps, since omitting a row reads as continuity), and every project a record named. A record with no project is its own row, never folded, so the figure stays honest.

Both groupings sum to the period total exactly. The CLI's cost-report-display mirrors the plugin's render functions; both have tests including the hundred-session reconciliation that confirms the byte identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…efact

A person asking what last month cost does not know which axis answers them. Phase 3 moves the burden from the command-line flags onto the question the person came to ask: what did this cost (total), what changed (by day), where did it go (by step/model/tool/project)?

The skill's SKILL.md offers these in plain language; actions/03-report.md names the axis that answers each. The plugin computes nothing - it reads the envelope exactly as it does now. The artefact rendered matches the axis: a line for a total, a table for a breakdown, a markdown series for a timeline.

Every figure in an artefact reads identically from the envelope. The cost-report-contract documents the envelope shape and what each axis can deliver. Test coverage includes the full artefact set and the byte-identity validation across plugin and CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The rebase onto next kept this branch's extracted writeSkillTree, which
had no transform parameter, beside next's call site that passes one. The
type error was the only thing that noticed; without it a tool's SKILL.md
transform would have been dropped in silence on every marketplace build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…he test

git exports GIT_DIR into every process it starts, so under a pre-push hook
`git init` on a temp directory left it pointing at the outer repository. The
journal then wrote somewhere else and four tests failed — only ever inside a
git hook, which is the one place nobody runs them by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
@blafourcade
blafourcade force-pushed the claude/aidd-telemetry-layer-e403uf branch from 1015566 to 2867585 Compare August 22, 2026 10:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment