Skip to content

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

Open
blafourcade wants to merge 142 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 142 commits into
nextfrom
claude/aidd-telemetry-layer-e403uf

Conversation

@blafourcade

@blafourcade blafourcade commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

🎯 What & why

Know what a feature cost — tokens, models, time — across Claude Code, Codex, Copilot, Cursor and OpenCode, by reading the files those tools already write. The aidd-telemetry plugin does it standalone: no CLI, no account, no endpoint, nothing leaving the machine.

🛠️ How it works

Three skills, and nothing else to install: 00-init allows measurement for a project, 01-cost answers what a period or a task consumed, 02-check says whether the chain is actually recording.

One contract, five adapters. Each tool's own file shape is mapped to one record by a per-tool adapter (claude-code-transcript.ts, codex-rollout.ts, copilot-events.ts, opencode-export.ts), against a closed allowlist in metrics-contract.md. A field not on that list cannot be stored, so no prompt, code or diff ever can be.

An unknown is never a zero. Cursor writes no token count anywhere; Copilot writes a session total and never a per-request figure. Both are reported as what they are, beside the figure, rather than as a 0 that reads as free. The same rule governs attribution: every record says how strongly it is attributed — tool-stated, journal-interval, or unattributed — and an unattributed turn stays its own row instead of being folded into the nearest step.

The sink is append-only. A stored record is never corrected in place, only reconciled on read. That is what lets a session that was read while still running be superseded later by a strictly better reading of the same turn, rather than frozen partial.

Two mirrors, pinned equal. The CLI and the plugin compute the same report from the same records, and a parity suite compares them — field values, and now also rendered row order, each guard verified against a planted divergence.

The one that mattered. Claude Code writes a line when a message starts and again when it completes, sharing one message.id. The accumulator kept the first — a placeholder carrying output_tokens: 3 where the real figure was 329. Measured across 1,604 real transcripts, that was 37% of output tokens lost, and no test saw it: the code was consistent with what it asserted about itself. The fix is validated against Claude Code's own /usage screen over 15,684 billed calls — output and cache-read agree to ~3%, and input/cache-write differ by construction, because /usage weights a 1-hour cache line by its price while a transcript counts it once.

The figure this produced. From a real three-skill chain, the breakdown against its own total:

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. That run is history, not a guard: no test reproduces those exact totals. What CI enforces is the invariant behind them, that every breakdown reconciles to its own total exactly, on both sides.

🧪 How to verify

cd cli && pnpm test:unit && pnpm test:integration && pnpm test:e2e
node --test "scripts/__tests__/*.test.js"
node scripts/check-cli-layering.mjs && node scripts/check-markdown-links.js

At b7141508: unit 2044, integration 608, e2e 194, plugin 518, all green; tsc, biome, layering and link checks clean. cli / Windows runs the same chain and the same three suites on windows-latest.

End to end, on a real repository:

node plugins/aidd-telemetry/skills/00-init/scripts/telemetry-switch.js on
# work as usual, then
node plugins/aidd-telemetry/skills/01-cost/scripts/telemetry-report.js report
node plugins/aidd-telemetry/skills/02-check/scripts/telemetry-check.js

⚠️ Heads-up

Found by running, not by reading. Each of these was invisible to a green suite:

  • skills/_shared/ did not survive an install. Four of the five flat contracts rename every immediate child of skills/ to <plugin>-<child> (flat-paths.ts:44), so require("../../_shared/readers.js") resolved to a name that no longer existed and the reporter died at load. The install-shape test copied skills/ verbatim and could not see it. Each skill now carries its own scripts/lib/, pinned byte-identical, and a cross-skill require fails the suite.
  • A directory marker does not survive a rename. The plugin declared "type": "commonjs" through four package.json files, and the hyphen-flat rename above moved skills/package.json away from the scripts it covered. Every CommonJS file it ships is now .cjs, which Node reads as CommonJS whatever the host declares, and a package.json anywhere under the plugin fails the suite. hooks/opencode-plugin.js is the one exception: OpenCode auto-discovers {plugin,plugins}/*.{ts,js} and nothing else — measured, when renaming it .mjs made OpenCode find it, log it, and never run it.
  • Cursor never loads a plugin's own hooks.json. Seven declared events, three probes, headless and interactive: not one fired. The project's own .cursor/hooks.json fires normally — the obstacle was where we installed, not Cursor.
  • OpenCode runs only a genuine ESM export, and auto-discovers {plugin,plugins}/*.{ts,js} — never .mjs.
  • Codex will not run a hook nobody approved, and says nothing. Trust is per entry, so a renamed event inherits no approval, and a headless run never sees the prompt.
  • os.homedir() never reads $HOME on Windows, it reads USERPROFILE. The plugin resolved HOME || homedir(), the CLI called bare homedir() — so a Windows run read 43,853 tokens from fixtures a POSIX run read 183,939 from.

Known limits are in the plugin's README, each with its measurement. The three that bite first: Codex needs one interactive approval; OpenCode misses a server process's first session, and opencode run is always a first session; only Claude Code's writes name a task.

Not met, under issues that are closing. #703's "a session that resolved no skill is distinguishable from one that needed none" has no implementation. #694's 2000-entry cap was measured against a synthetic tree, not the real one its bullet names. #631's evidence reads "the same skill proves both"; it is two, 01-cost and 02-check.

Unrelated reorg riding along. 12 files move from aidd_docs/plans/ to aidd_docs/tasks/ (R100 renames, no content change). Nothing in this branch depends on it; say the word and it comes out.

Left open deliberately. #702 (the install shape is reconstructed by the guard, not driven through the real translator) and #683 (hooks/lib/host.js still names tools in executable code, so a sixth tool is still two files).

🔗 Linked issue

These do not fire on this merge. GitHub auto-closes only into the default branch, and this targets next. The list is the record of what the branch delivers; the issues need closing by hand at merge time, or when next is promoted.

Closes #617, #631, #676, #680, #681, #686, #688, #693, #694, #695, #697, #698, #699, #700, #701, #703, #704, #705

✅ I certify

  • I DO CERTIFY I READ EACH LINE OF THE PULL REQUEST BECAUSE I AM A SOFTWARE ENGINEER, NOT A AI PUPPY.

@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 6 commits August 23, 2026 08:54
…h machine (#707)

The two probe steps that were still red were not tests - they ran the
raw `find` line to measure #707's own question, and they answered it.
Under Git Bash `find` exits non-zero for every plugin directory a fresh
machine does not have, so the line prints the right path and still
reports failure to any shell that stops on one. Under PowerShell `find`
is Windows' own unrelated FIND.exe: "Parameter format not correct",
exit 2.

The PowerShell half was already fixed - each locate action carries a
form per shell. The bash half was not: it still ended in a pipeline
whose status came from a `find` walking directories that are absent by
design. `|| true` closes that, and the comment says why so nobody
tidies it away.

The probe steps now run the forms the actions document, rather than the
raw line. A step that only records a defect stops being useful the
moment the defect is fixed; this one now fails if the documented
instruction ever stops finding the script, on either shell.
… and a session says its worktree (#686, #695)

#686. Claude Code marks its own composed notices `model: "<synthetic>"`.
Measured against every local transcript: 251 such lines, all four
counters zero on every one, and `<synthetic>` the only angle-bracket
placeholder any of them used for a model. They were still yielding a
record, so the request count was inflated and a by-model breakdown
listed something nobody was ever charged for.

The filter is the marker, deliberately not `isApiErrorMessage`: those
lines carry it too, but a future error that *was* billed must keep its
record - an unknown is never a zero, and a record silently dropped is
worse than one wrongly kept, because nothing downstream can tell it was
ever there. A message with all-zero counters that is not marked stays.

Guarded before the dedupe key on both sides, so a real call sharing the
synthetic line's requestId still lands. Both readers, because the
parity suite compares them field for field.

#695. `session_start` now names `worktree_id` and the repository those
worktrees share. Two fields rather than one because `project_id` does
not identify the shared repository: with no remote it falls back to the
worktree's own directory name, so two worktrees of one remote-less
clone carry two different project ids and nothing on the line would say
they belong together.

A plain checkout carries neither field - absent, not null, not an empty
string, so it cannot group with a placeholder - and costs no extra git
call: one `rev-parse --show-toplevel --git-common-dir --git-dir`
replaces the one that was already there. Detection reads the layout
(`.git/worktrees/<name>`), never a path comparison, so no Windows
drive-letter spelling can make one path look like two. One real false
positive found and closed on the way: a plain checkout living in a
directory literally named `worktrees` was being recorded as a worktree.

Stops at the journal line and the reader, per #695's own out-of-scope:
what a report does with the field follows #693, and no dimension or
filter groups on it yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…s in src (#688)

46 widening casts to 1, and nothing swapped for another hole: no `as
any`, no `@ts-ignore`, no `@ts-expect-error` added, and `as never`
unchanged at its 45 existing sites.

Almost all of them were a double that could not satisfy its port. Twelve
were superstition - the literal already typed, or carrying methods the
port never had (one FileReader double declared four writes the reader
port does not have, and lacked the one method it does). Four became the
real class with a faked one-method port. The remaining seventeen were
consumers depending on a whole use-case to call one method: ten
interfaces now name what each consumer actually needs, each implemented
by the class it was extracted from, with no runtime change.

The rest were values invented through a cast. A FileHash validates 32
lowercase hex, so "h" could never be one - those tests now hash through
the real hasher. `ReturnType<typeof execSync>` is `string | Buffer`, so
a double returning `undefined` was describing something execSync never
does.

One cast survives, in CASTS_ALLOWED with its reason: a type carrying an
index signature beside typed optional members admits no
Record<string, unknown>, and closing it would either change what a
nameless catalog does on flat targets or silently drop non-string
values that pass through unchanged today.

The guard now reads `cli/tests` as well as `cli/src` for the cast rule,
while the layering rule stays over `src` alone - a test may legitimately
wire an adapter to a use-case. A CASTS_ALLOWED entry nobody spends is
itself a breach, so the list can only shrink. All three paths proven by
planting a violation and restoring.

The rule itself was enforced and never written down; the hexagonal rules
now carry a Type honesty section, which is what the guard's failure
output points readers at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…rned out to be (#707)

The probe passes on a real windows-latest runner: every probe step
passed, plugin suite 482 with 0 failing, unit 177/177, integration
59/59, e2e 22/22. The one skipped test names its own reason and does not
leave Windows privacy unasserted - the ACL is read back with icacls and
finds the current user alone, in the same checkout where git add -A
succeeds.

Records the two causes the first reading of the failure set missed: the
home-directory asymmetry, which was the only one that changed an answer
rather than a test, and the last two red steps, which were never tests -
they were the probes measuring #707's own question, and they had already
answered it.
)

31 assertion sites to 0. The rule said "no type is widened through
unknown", and that was satisfied in letter while the identical lie lived
one keyword away, invisible to the guard that enforces it.

21 of them went with one change: GhTokenAdapter, the release resolver
and the raw fetcher now take HttpGet, the way SelfUpdaterAdapter already
did, so all four consumers of that interface agree. The doubles became
real implementations and kept the same spy, so no assertion moved.

The rest were doubles standing in for a class with private members - two
more interfaces name what each consumer needs - or doubles of a port
that already had a real in-memory implementation sitting unused beside
them.

One cast was hiding a live landmine. In setup-auth-guard, an object with
only `execute` was passed as the marketplace *registry*, which needs
`list`; `as never` made it compile, and the only reason it never blew up
is that every test in the file returns before the registry is touched.
The real InMemoryMarketplaceRegistry is there now.

And one changed how a test is written, for the better. A double for
`process.exit` returned undefined through a cast, so five tests walked
past a point the real process never reaches. It throws a sentinel now -
which is what "does not return" looks like in-process, and which
TypeScript infers as never with no cast. Every original assertion is
intact and each test additionally proves control stopped there.

The guard checks both spellings over both trees, with a word boundary
that keeps the fourteen prose lines saying "was never" out of it -
proven by planting each spelling and by a control run asserting those
fourteen stay silent. `lefthook.yml`'s cli-layering hook was globbed to
`cli/src/**`, so since the guard learned to read `cli/tests` the rule
would have been enforced by a script no commit ever ran; it reads
`cli/**` now.

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

It was the one suite creating a temp repository per run and never
removing it. Found the hard way: mkdtemp itself failed with ENOSPC
during a routine run, with 1878 abandoned aidd-* directories and 37 GB
under the OS temp folder.

Proven from a clean slate: the directory count is 0 before the run and 0
after it, where it was 1 per run before.
Test and others added 23 commits August 23, 2026 10:27
…Stop (#707)

Found by running the chain against the real binary rather than reading
the translator: every Codex session journalled a session_start with
nothing after it. 13 PASS, 5 FAIL.

Codex's own event vocabulary, read out of the 0.149.0 binary, is
PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact,
SessionStart, SessionEnd, SubagentStart, SubagentStop. There is no
`Stop`. The Codex translation copies Claude's event names verbatim, so
the turn-end hook was subscribed to an event that does not exist and
nothing said so - the same silence as Cursor's headless `sessionEnd`
(#680) and Codex's own untrusted hook (#699).

Confirmed live, not inferred: a `codex exec` run with SessionStart,
PostToolUse, SessionEnd and Stop all subscribed fired SessionStart and
SessionEnd, 351 and 287 bytes of payload, and never Stop.

SessionEnd is coarser than Stop by nature - it bounds the session, not
each turn. For `codex exec` the two coincide; for an interactive session
one turn_end bounding the whole session is the honest answer rather than
none at all, and the journal already tolerates more than one turn_end
line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The mapping fix was pinned at the translator and nowhere at the hook, so
nothing proved journal.js can read a session out of the payload Codex
actually sends on SessionEnd. Measured rather than assumed: SessionEnd
carries session_id, transcript_path, cwd, hook_event_name and reason -
the first two are what readSessionId reads, in that order - and both
match the SessionStart of the same session exactly.

Fed to journal.js turn-end, that capture appends the turn_end line. The
fixture is the real payload with the home path redacted, and the test
fails if a Codex turn ever stops closing.

This is the half of the Codex chain that does not need a model call, so
it is provable while the account is rate limited. task_declared still
needs a real tool call and stays unproven until it is not.

Plugin suite 483 -> 485.

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

The rename shipped on the merged-config route and the built-tree route
kept `Stop`, so a real `aidd setup --ai codex` still produced a hooks.json
subscribing the turn-end hook to an event Codex does not have. The chain
run said so plainly: 13 PASS, 5 FAIL, unchanged by the previous fix.

That is the third time these two routes have drifted. The rename is now
one exported function, renameCodexHookEvents, and both call it - the
merge path and the Codex build contract's hooks transform.

Proven on a real install, not only in a test: a built tree that read
["SessionStart","Stop","PostToolUse"] before now reads
["SessionStart","SessionEnd","PostToolUse"].

The build route gets its own pinning test, seeded with a Stop event
because no fixture plugin declares one - a test asserted against a tree
that cannot exercise the rename would have passed before this fix too.

Integration 594 -> 596.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…se (#707)

Two verdicts were FAIL where the honest answer was a reason, and both
reasons matter to whoever reads the report next.

Codex keys hook trust per entry -
`<plugin>@<marketplace>:hooks/hooks.json:<event>:<matcher>:<hook>` -
measured on a real config.toml, which held `session_start`,
`post_tool_use` and `stop` for this plugin and no `session_end`. So
renaming the turn-end event to the one Codex actually delivers created a
new key that inherits no approval, and until someone grants it the
session journals a session_start with no turn_end. That is
indistinguishable at a glance from the mapping bug just fixed, which is
exactly why it needed naming: the bypass variant beside it closes the
turn and proves the mapping is right.

The lookup is scoped to this plugin's own key. A bare `session_end`
substring matched another plugin's approval on this machine and read it
as proof about ours - caught by the verdict not changing.

The second: a session the account was not allowed to run made no model
call and so no tool call, and nothing downstream can ask what it
declared. `task_declared` says that instead of reporting a missing line.
An unknown is never a zero, and that rule binds the verifier too.

Codex: 13 PASS / 5 FAIL -> 15 PASS / 0 FAIL / 3 SKIP, every skip
carrying its own cause.

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

`opencode run` had been failing for two rounds with `build · big-pickle
... exit -1` - whichever model it picks on its own varies per machine
and per account, and this one's default is broken. Naming a free model,
the same way the Codex path names `-m gpt-5.4`, makes the route actually
run. AIDD_VERIFY_OPENCODE_MODEL overrides it where a catalog differs.

With the route running, it reports the limit that was already written
down rather than an anonymous failure: OpenCode's plugin is loaded
lazily by the request that creates the session, so session.created is
published before a handler exists, and a one-shot `opencode run` starts
its own server and is therefore always a first session. Declared ahead
of the run in RUN_FILE_EXPECTATION, worded from
docs/telemetry-limits.md, so a route that starts journalling one day
turns the claim green instead of quietly staying skipped.

The free serve+curl proof beside it still shows a real session_start,
which is what makes this a first-session gap rather than a dead plugin.

OpenCode: 14 PASS / 1 FAIL -> 14 PASS / 0 FAIL / 3 SKIP.

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

Found by installing the plugin into an ordinary ESM project rather than
by reading the code: `telemetry-switch.js on` died at its first
`require` with "require is not defined in ES module scope", before a
line of its own ran. Node takes a `.js` file's module system from the
nearest package.json walking up, and a project-scope install -
`.claude/plugins/`, `.github/plugins/`, `.codex/plugins/`, all inside
the project - sits under the host's own declaration.

`skills/package.json` declares `"type": "commonjs"` so the walk stops
there. It lives inside `skills/` rather than at the plugin root because
that is what an install carries: a built tree holds `hooks/`, `skills/`
and the manifest, and nothing else. Two more install-shape suites run
every skill script inside an ESM host, and eight of them fail without
that one file.

`hooks/` deliberately gets no such file, and the reason is measured:
it holds one genuine ESM module, `opencode-plugin.js`, and OpenCode's
loader refused an `.mjs` rename - the chain went from 14 PASS to a
failing session_start and back once reverted. A `"type": "commonjs"`
there would trade a working OpenCode for a fixed hook path. The gap is
named in docs/telemetry-limits.md with the workaround, rather than
closed by breaking the tool it was closed for.

Plugin suite 485 -> 495.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…ot exist (#707)

The previous commit closed this for the skills and declared the hooks a
deliberate trade: `hooks/` holds one genuine ESM module and OpenCode
"refused an .mjs rename", so a commonjs marker there was said to cost a
working OpenCode. That was wrong, and wrong for a specific reason worth
recording: two things were changed at once.

OpenCode auto-discovers `{plugin,plugins}/*.{ts,js}` and nothing else -
its own source comment says so - so the rename alone was the whole
cause. It loads that file with its own runtime, which never consults
Node's `type` field. Measured by putting the marker back with the name
left alone: a real OpenCode session journals its session_start exactly
as before, 14 PASS.

So `hooks/package.json` joins `skills/package.json`, and every script
this plugin ships now runs the same whether the host project declares
`"type": "module"` or not. The workaround the docs offered - install at
user scope - is deleted rather than left standing beside a fix.

The one test that loaded the plugin with plain Node imports a
byte-identical `.mjs` twin it makes itself: a directory-wide override is
not available there, because `journal.js` and `lib/` are its CommonJS
siblings in the same directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…pened it (#707)

The layer's largest counter was wrong by more than a third, and its own
comment is what stopped anyone checking.

Claude Code writes a line when a message starts and again when it
completes, sharing one `message.id`. The reader kept the first, on the
strength of a comment asserting both lines "carry the same usage".
Measured across 1,604 real transcripts on one machine: of 83,626
`message.id` groups, 25,702 carry differing figures, and in 25,702 of
25,702 the last line's output_tokens is greater than or equal to the
first's. 825 files affected.

  kept (first line)  32,884,131 output tokens
  true (last line)   52,497,020 output tokens
  discarded               37.4%

A subagent-heavy session loses far more - up to 94% of its output
tokens. Because the loss sits in output_tokens, the most expensive
counter, any cost derived from it was understated by more than 37%. The
report's shape was intact, so nothing signalled it. And
read-local-cost-use-case dedupes later reads on the same turn_id, so a
corrected figure could never land: the loss was permanent.

The last line wins and the figures are never summed. In 25,143 of those
25,702 groups input_tokens and cache_read_input_tokens are identical
across the lines - one call restated, not two calls - so adding them
would multiply the cache counters, by far the largest. The worked
example in the new test is a real capture: input 2, cache_creation
37,477, both unchanged, output 3 -> 329.

Both mirrors, since the parity suite compares them field for field. The
event_timestamp now names when the call finished rather than when it
started, which is also the moment its day row is keyed on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Each was found by planting a defect and watching it stay green.

`capture()` in the parity suite returned a failed command's output and
threw away its exit status, so all eleven tests were blind to failure: a
script printing the right answer and exiting 3 read exactly like one
that succeeded, and two sides failing with identical text compared
equal. It now throws, naming which side exited what. The two tests that
drive an invalid invocation on purpose ask for a refusal explicitly -
a refusal is an answer, anything else is not. Verified by re-planting
the exact defect: "the plugin script exited 3 for [read]".

`telemetry-where-things-live.test.js` asserted the Windows figures
location inside `if (process.platform === "win32")`, so it never ran on
any developer machine or POSIX runner. A planted defect sending the
Windows sink to `.config` stayed green there and across the whole plugin
suite. It now states the platform the way the identity tests next door
already do, and the same planted defect turns it red.

`"Codex's SessionEnd names the same session"` - mine, written earlier
today - compared two fixtures to each other and executed no product
code at all. It resolves through readSessionId now, and exercises the
fallback path too.

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

`.aidd/config.json` is a file a repository carries, and `aidd telemetry
on` reused whatever endpoint it found there. So one person running
`--endpoint https://elsewhere` once and committing the result silently
armed every later run for everyone who cloned it - no prompt, no
warning, no mention that the host was remote. What Claude Code exports
carries `user.email` and `user.id`; the repo's own captured fixture
shows both, and its plan notes they arrive whatever the detail flag
does. So the endpoint was the difference between "nothing leaves your
machine" holding and not, decided by a URL nobody looked at.

The only check was shape: http or https, any host.

A loopback endpoint is unaffected - it names this machine, which is the
documented shape, and re-arming it changes nothing about where the
figures go. A remote one read from the file is refused, naming the file
it came from and what typing the flag would mean. A remote one actually
typed is accepted, with a warning saying what it sends.

The reuse test was passing on a remote endpoint, which is the case now
refused; it keeps its claim on the loopback case, and two tests cover
the refusal and the typed acceptance.

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

**One billed call counts once, whichever route saw it.** Claude Code's
export keys turn_id on prompt.id (one user turn) and the local read keys
it on requestId (one billed call), so the dedupe could never match
across routes: a user with both enabled saw exactly twice the requests
and twice every token counter. The two routes do share a key - the
export's `request_id` attribute is the local transcript's `requestId`,
the same identifier for the same call - so records now carry
`billed_request_id` and are collapsed at report time.

Collapsed on read, not on write, because the sink is append-only: a
precedence rule would permanently discard whichever route's strength
lost, and only one route carries cost while only the other carries the
step the tool stated. The survivor takes money from whichever record has
it and attribution from whichever resolved one, chosen by content rather
than array position, so a reversed input or a retried OTLP delivery
answers the same. 6 requests -> 3 on the repo's own fixtures.

**`aidd plugin remove` now removes.** It reported success, `plugin list`
agreed, and every host registry still carried the plugin pointing at a
live cache - the hook kept firing after removal. Removal drives each
host's own CLI, the way install did, rather than editing another
program's config behind its back; `--scope project` is repeated because
uninstall otherwise defaults to user scope and misses the entry. A host
whose CLI is absent is named in a warning instead of being reported
clean.

**OpenCode's two install routes finally agree.** `genericFlatSkillPath`
concatenated `<plugin>-<skillRelPath>`, which assumed every child of
`skills/` is a skill: `_shared/` landed as `aidd-telemetry-_shared/` and
three of four skill scripts died on `Cannot find module
'../../_shared/attribution.js'`. The trees the two routes produce are
now identical, and a test compares them - the fourth drift between these
two routes, and the first one a test could have caught.

That layout change shortens a skill's own name to its leaf, which the
plugin prefix used to make collision-proof by construction. No two
plugins share a leaf today; a guard now says so, rather than leaving
OpenCode to resolve whichever of two it likes.

**The cost skill's tests assert behaviour.** 21 tests of
`everything.includes(...)` over concatenated markdown were the only
guard on what that skill tells an agent, and a planted section telling
it to scrape the human table and invent a price passed all 21. They are
closure tests now: every flag the skill names must exist in the script's
own flag sites, every axis must match render.js, every field must
resolve on an envelope the script really emits, and every `report` call
must ask for the object. The two claims that genuinely cannot be checked
from a file say so in their own comment instead of pretending.

unit 2019 · integration 608 · e2e 191 · plugin 501 · tsc clean · biome
clean · layering clean · links clean.

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

Checked against Claude Code's own /usage over one real session, 15,684
billed calls. Output and cache-read agree within a few percent. Input
and cache-write do not, and neither is an error:

A transcript's input_tokens really is two or three tokens on a cached
call, because everything else arrived as cache; /usage's 'input' is the
whole prompt. And a cache write is billed by how long it lives - the
transcript's cache_creation_input_tokens equals the sum of its own 5m
and 1h breakdown exactly, so the field read here is the right one, and
weighting the 1h half the way the price does lands on /usage's number.

Written down because someone will compare the two and conclude this
layer is wrong. What it counts is unweighted, unpriced and identically
shaped across tools, which is what makes tools comparable at all.

Also states the thing no measurement here can settle: whether a tool's
own file matches what the provider counted. On a subscription there is
no per-token invoice to check against, which is why the report says
amount unknown instead of inventing one.
Eight defects, each re-verified at HEAD by file and line, split by what
can be verified independently rather than by which file they sit in.

Phase 1 is the only one a person can hit today: a Codex turn read while
it runs is stored partial, and the turn_id dedupe makes that permanent.
Its fix rides on the billed_request_id collapse built for the
cross-route duplication, because the sink is append-only and a
correction can only ever be a later line that supersedes.

Phase 2 is two latent violations of this layer's own rule, both inside
the module written to enforce it: byModels drops a record the other
breakdowns would give a row, and cost is guarded differently from every
token counter so a non-numeric amount becomes a hard zero.

Phase 3 is what the parity suite cannot see by construction - it
compares field values, so the two implementations ordering rows by
different weights, and placing an empty project id differently, both
passed it green.

Phase 4 decides rather than fixes: every OpenCode capture in this repo
is from one provider, and the cache arithmetic that holds for it may
double-count for another. Guessing would produce a figure
indistinguishable from a measured one.
Two things the plan asserted turned out to be wrong or already done, and
both would have cost an executor a wrong turn.

The Codex fix cannot ride on the billed_request_id collapse. Only the
Claude Code reader sets that field, and the OTLP allowlist maps
request_id onto it - Codex, Copilot and OpenCode records carry none, so
the mechanism does not reach the tool this defect is about. Codex's own
turn_id is unique per record, which makes it a sound supersede key for
the local-read route and a wrong one for the export route, where a
turn_id is a prompt id shared by several billed calls. Superseding a
re-read and collapsing two routes are two mechanisms that look alike.

And the retried-delivery task is mostly already true:
mergeBilledRequestGroup picks a survivor rather than summing, and its
own comment names OTLP redelivery. What remains is proving it with a
test and establishing which routes carry no request_id at all.
**A turn read while it runs is no longer the last word.** A Codex turn
read mid-session was stored partial, and the turn_id dedupe made that
permanent - 48,896 cache-read tokens frozen where the finished turn had
99,840. A candidate now supersedes when it strictly improves: every
counter greater than or equal, at least one greater. A strictly larger
reading is itself the only proof needed that an earlier one was not
final, and idempotence falls out of it - a re-read bringing nothing new
stores nothing new.

The first attempt gated corrections on the journal's `turn_end` and was
wrong: that line says no further growth is coming, never that the
reading already stored saw all of it, so it re-created the very defect
it was meant to close. Reproduced mechanically before being removed.

The sink still holds both lines - it is append-only - and only the built
report collapses them.

**A retried export is proven, and its residual named.** The collapse
picks a survivor rather than summing, so a redelivered payload carrying
`billed_request_id` moves no counter; there is now a test that fails if
it ever starts summing. Of the five tools only Claude Code's export has
ever carried that id, so a redelivered Codex-shaped payload doubles -
pinned by its own test and written into the contract as a live gap.

**An unknown keeps its row.** `byModels` dropped a record with no model
while its neighbours gave one an `unattributed` or unknown row, so the
breakdown could stop reconciling with nothing naming the gap. Cost was
guarded by `!== undefined` where every token counter was guarded by
`typeof === "number"`, so a non-numeric amount became a hard zero -
"free" - in the module written to forbid exactly that; the deeper fix
rejects it at write time, so `Number("")` and `Number(false)` cannot
fabricate one either. And a damaged moment answered a sliced fragment
that matched no day, dropping the record from `byDays` while it stayed
in the total - fixed in all three copies, `sink.js` included.

**The two implementations agree, order included.** They weighted a
costless row differently - two counters against four - and every tool
here runs above 90% cache, so they ordered the same rows in opposite
directions. Four counters wins: the renderer already prints that sum
beside the row, so the old weight sorted by a number the report never
shows. An empty project id is now unplaced on both sides rather than
becoming its own nameless row.

The parity suite compared field values, so both divergences passed it
green. It compares the rendered answer now, and the extension is proven
by planting an order-only divergence in one side and watching it fail.

`cost_report_version` 2 -> 3: `byModels[].model` becoming optional
changes what an existing field means, which the contract says is a bump.

unit 2044 · integration 608 · e2e 194 · plugin 508 · tsc clean · biome
clean.

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

`skills/_shared/` did not survive an install. Four of the five flat contracts
(claude, cursor, codex, copilot) rename every immediate child of `skills/` to
`<plugin>-<child>`, so `require("../../_shared/readers.js")` resolved to a path
that no longer existed and the reporter died at load. Reproduced from HEAD's own
tree: "Cannot find module '../../_shared/attribution.js'". The install-shape test
copied `skills/` verbatim and could not see it.

Each skill now carries its own `scripts/lib/`, its own `package.json` commonjs
marker (the old `skills/package.json` was renamed by the same rule), and reaches
nothing outside its folder. Three guards, each verified against a planted defect:
the hyphen-flat shape now runs every script, the copies are pinned byte-identical,
and a cross-skill require fails the suite.

Also in this pass, from review feedback:

- The Windows probe becomes a Windows job. The `#707 answer N` steps and the
  continue-on-error/recompose machinery are gone; the chain and the three suites
  run and fail normally. 339 lines to 203.
- 126 task, spec and brainstorm documents leave the branch. The two product
  contracts and the journal's README stay.
- Issue numbers leave the code, including three that reached a person: the CLI
  printed "tracked in #653", the post-enable notice named an issue that has since
  landed, and the cost skill answered "per person" with #660, #661 and #656.
- `docs/telemetry-limits.md` is folded into the plugin's own README, where a
  reader of the plugin already is.
- The FAQ's measurement section says what to do (allow it, ask for `00-init`,
  then ask `01-cost`) rather than restating the contract: 53 lines to 24.
- The three CLI rule files go back to what `next` has.

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

Follows the `_shared` removal. Four plugin suites and three CLI helpers still resolved
`skills/_shared/`; they now read the skill that owns each file. Two assertions pinned
issue numbers that had just left the product's own output, and one assertion required
the copies NOT to exist - replaced by the equality it was really protecting.

`stays small enough that nobody skips reading it` went 1950 -> 2550. The 771 lines that
moved in from `skills/_shared/` were always installed; they sat in a directory the
measurement did not look at, so 2458 is the first honest reading of it, not growth.

Verified at this commit:
  cli unit         2044 passed (178 files)
  cli integration   608 passed (60 files)
  cli e2e           194 passed (24 files)
  plugin suite      518 passed
  tsc --noEmit      clean
  biome check       clean
  check-cli-layering / check-markdown-links  clean

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

Three comments still justified OpenCode's nested skill path by `skills/_shared/`, and
one of them was the false premise itself: an e2e test wrote down that report.js "reaches
../../_shared/attribution.js by a relative path that only resolves correctly with _shared
still a sibling" - true for OpenCode alone, and the reason the other four shipped a
reporter that died at load. The nesting stays, because it is the shape already installed;
its reason is now the one still true, and says plainly that it licenses nothing.

Issue numbers also left scripts/, which the previous sweep did not reach. Two of them
were printed at whoever ran verify-chain.mjs, the same defect as the three already fixed
in the product's own output.

cli/.claude/rules/00-architecture/0-hexagonal.md keeps two lines of what it gained: the
`tests/**/*.ts` path entry and the type-honesty constraint. check-cli-layering.mjs
enforces both over both trees, and a rule CI enforces that no session ever loads is worse
than a verbose one.

plugin suite 518 passed, tsc clean, biome clean, the two renamed-fixture tests green.

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

Every CommonJS file the plugin ships is now `.cjs`, and the four `package.json`
markers are gone. Same code, no rewrite: `.cjs` is CommonJS to Node whatever the
host project declares, which is exactly what the markers were there to force.

A marker is a property of a directory, and this plugin already learned what that
costs - the hyphen-flat install contracts rename every immediate child of
`skills/`, so `skills/package.json` was renamed away from the scripts it covered.
An extension travels with the file through every route.

`hooks/opencode-plugin.js` keeps its name and stays the one exception. OpenCode
auto-discovers `{plugin,plugins}/*.{ts,js}` and nothing else - measured, when
renaming it `.mjs` made OpenCode find, log and never run it. It is genuine ESM,
and with the hooks marker gone Node now reads it as ESM too, which it always was.

`.mjs` was considered for the other 39. The plugin uses no `__dirname`, no
`__filename`, no `require.cache`, no dynamic require - so it was feasible - but it
is 40 files rewritten to `import`/`export` plus `require.main` reworked, for no
behaviour a plugin invoked as `node <script>` can use.

The guard is inverted rather than deleted: a `package.json` anywhere under the
plugin now fails the suite, and `.js` is allowed for exactly one path.

Verified at this commit:
  cli unit         2044 passed (178 files)
  cli integration   608 passed (60 files)
  cli e2e           194 passed (24 files)
  plugin suite      518 passed
  tsc, biome, check-cli-layering, check-markdown-links  clean

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
`path.relative` answers with backslashes on Windows, so the new guard compared
`hooks\opencode-plugin.js` against `hooks/opencode-plugin.js` and failed the whole
suite on `cli / Windows` while passing everywhere else. Normalised to `/` before
comparing, which is what every other path assertion in this file already does.

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

Found by re-deriving 400 real rollouts in ~/.codex/sessions against the adapter,
not by a test: the last `token_count` of a turn is sometimes emitted again
verbatim - the same `last_token_usage` arrives twice while `total_token_usage`
does not move. Summing every increment therefore counted that call again.

Measured over 16,415 events: 291 repeats (1.8%) across 38 of the 400 rollouts,
inflating this tool by 0.9% on input, 0.9% on cache-read and 1.2% on output.

Reproduced from rollout-2026-08-05T09-42-34-019fd0df:

  token_count  last=33701  total=75255
  token_count  last=45800  total=121055
  token_count  last=45800  total=121055   <- counted again

The guard is a consequence of the format rather than a guess about it: a
cumulative that has not moved cannot carry consumption that was billed. An event
stating no cumulative at all is still counted, because an absent figure is not
evidence that nothing happened.

Both mirrors, both pinned. The CLI's guard is mutation-verified - removing the
condition turns the new test red (49,640 where 32,554 is correct).

Same class as the Claude Code defect this branch already fixed: a tool restating
a line, and a reader believing it twice. The fixtures could not see it because
they were captured by hand and none contained the repeat.

  cli unit      2046 passed
  plugin suite   519 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The push of 2ee4d68 fired pull_request_target but not pull_request, so CI,
Validate and cli CI produced no run for that commit. Nothing else changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The PR had gone `DIRTY`, and that is why no check had run since 25/08: a
`pull_request` workflow executes against the merge commit, and GitHub cannot
compute one for a conflicting PR. `pull_request_target` runs against the base
and needs no merge — which is exactly the one event that kept firing.

Six commits arrived on `next`, five of them dependency bumps. The single
conflict was README.md's generated counts line, against #664's repositioning:
resolved to next's shape (which drops the MIT badge) and regenerated, giving
8 plugins / 50 skills / 2 agents.

Biome moved 2.5.8 -> 2.5.10 and reformatted the Codex test added in 2ee4d68.

  cli unit         2046 passed
  cli integration   608 passed
  tsc, biome, layering, markdown links  clean

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment