feat(framework): measurement that works on five tools, each proven by a session that ran - #706
feat(framework): measurement that works on five tools, each proven by a session that ran#706blafourcade wants to merge 142 commits into
Conversation
What to read, and why the file count liesGitHub says 1801 files. 142 of them are this work. The branch was cut on 16 July at The eleven commits that are this work, from Compare view for exactly those: 0509345...1015566 The base needs a decision before this leaves draftThree ways out, none of which I will take unasked because they all rewrite or reshape someone else's branch:
Everything else is readyEleven 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, Every per-tool claim in the body rests on a session that was actually run, and the negative results are in |
Retargeted to
|
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>
…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.
…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
🎯 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-telemetryplugin does it standalone: no CLI, no account, no endpoint, nothing leaving the machine.🛠️ How it works
Three skills, and nothing else to install:
00-initallows measurement for a project,01-costanswers what a period or a task consumed,02-checksays 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 inmetrics-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
0that reads as free. The same rule governs attribution: every record says how strongly it is attributed —tool-stated,journal-interval, orunattributed— and anunattributedturn 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 carryingoutput_tokens: 3where the real figure was329. 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/usagescreen over 15,684 billed calls — output and cache-read agree to ~3%, andinput/cache-write differ by construction, because/usageweights 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:
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
At
b7141508: unit 2044, integration 608, e2e 194, plugin 518, all green; tsc, biome, layering and link checks clean.cli / Windowsruns the same chain and the same three suites onwindows-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.jsFound 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 ofskills/to<plugin>-<child>(flat-paths.ts:44), sorequire("../../_shared/readers.js")resolved to a name that no longer existed and the reporter died at load. The install-shape test copiedskills/verbatim and could not see it. Each skill now carries its ownscripts/lib/, pinned byte-identical, and a cross-skill require fails the suite."type": "commonjs"through fourpackage.jsonfiles, and the hyphen-flat rename above movedskills/package.jsonaway from the scripts it covered. Every CommonJS file it ships is now.cjs, which Node reads as CommonJS whatever the host declares, and apackage.jsonanywhere under the plugin fails the suite.hooks/opencode-plugin.jsis the one exception: OpenCode auto-discovers{plugin,plugins}/*.{ts,js}and nothing else — measured, when renaming it.mjsmade OpenCode find it, log it, and never run it.hooks.json. Seven declared events, three probes, headless and interactive: not one fired. The project's own.cursor/hooks.jsonfires normally — the obstacle was where we installed, not Cursor.{plugin,plugins}/*.{ts,js}— never.mjs.os.homedir()never reads$HOMEon Windows, it readsUSERPROFILE. The plugin resolvedHOME || homedir(), the CLI called barehomedir()— 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 runis 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-costand02-check.Unrelated reorg riding along. 12 files move from
aidd_docs/plans/toaidd_docs/tasks/(R100renames, 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.jsstill 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 whennextis promoted.Closes #617, #631, #676, #680, #681, #686, #688, #693, #694, #695, #697, #698, #699, #700, #701, #703, #704, #705
✅ I certify