feat(framework): measurement that works on five tools, each proven by a session that ran - #706
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
|
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>
The plan indexed three milestones and their issues. That index was a second copy of the GitHub backlog, which persistence.md forbids for exactly the reason it went wrong here already: a restatement drifts from its source while still reading as instructions. The issues are the backlog; the plan plans the building. One phase file carried the whole feature. Split so each phase ends on something observable: the plugin installs and a test can fail, the host is identified and nothing is written, the first file appears, the record is exactly ten keys, attachment produces intervals, and materialisation lands in git. The first five write outside the repository and are reversible; the last one is not, and stays blocked until its owner is confirmed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds aidd-telemetry, a plugin whose hooks write one record per session: which session, which project, which task, and when. Never a measurement. Tokens and cost stay in the provider's telemetry and are joined afterwards on session.id, so no figure that changes mid-session is ever copied into a file. Records live in aidd_docs/runs/, whose contents git ignores while the directory itself is committed: the directory is the opt-in, and a project that wants its records shared removes one line. Nothing is written until a repository opts in, which removes the public-repository requirement rather than implementing it. Four things were measured rather than assumed, and each changed the design. The host cannot be read from field names: Claude Code and Codex hand a SessionStart hook the same five keys. It cannot be read from the environment either, since a Codex session launched from inside a Claude Code session inherits CLAUDE_CODE_SESSION_ID from its parent. Detection reads the shape of transcript_path, and an unrecognised host writes nothing rather than writing a wrong tool name. The event name travels in argv because Copilot's payload carries none at all. hooks.json stays authored in Claude Code's shape, since the framework is Claude-oriented and the CLI adapts it; the neutral name rides in the command string, which the CLI passes through untouched. Attachment is observed, never declared. A write landing inside a task folder is what says a session is working on it. An earlier design had the planning and implementation skills write a pointer, which put a measurement concern inside code-transformation skills and hardcoded a Claude-Code variable into content shipped to five tools. ended_at advances on every event, not only at turn end, because Copilot has no turn-end event. This makes "the last observed turn" literally true instead of aspirational. Plans move from aidd_docs/plans/ to aidd_docs/tasks/, the layout the plan skill already writes and the journal reads. Two conventions coexisted, and the journal could not otherwise have attached this repository's own work. Closes #620 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"there is no AIDD server, account, or telemetry" was the answer people quote when asking whether the framework watches them, and shipping a session journal made half of it false. A stale line like that is worse than never having written it. Keeps the part that is still true — no server, no account — and states what measurement actually does, in the terms a reader deciding whether to install it needs: not on the curated install path, silent until a repository commits aidd_docs/runs/, never leaves the machine, records which session served which task and not what was typed, and never carries tokens or cost. Closes #658 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four phases for #646. The premise was verified first: Claude Code does read an env block from settings.json, and the documentation uses OTEL variables as its own example. Settings precedence turned out to be the plan's central fact. One scope is git-tracked and one is not, so choosing where to write is a sharing decision wearing a configuration costume — the plan defaults to the file that affects only the person running the command, and guards the shared one behind an explicit flag. Three corrections to the issue. Export consent and record-sharing consent were conflated; they are different questions and no longer share a mechanism. The consent file is dropped, because the settings file already is the record and a second copy would drift. And the public-repository guard is replaced by a tracked-scope guard: repository visibility has no bearing on where an export sends data, while the tracked file is what turns telemetry on for everyone who clones. Two additions the issue did not list: project_id in OTEL_RESOURCE_ATTRIBUTES, without which a sink cannot separate two repositories on one machine, and the logs exporter, without which per-step cost is unreachable since skill names are redacted on metric attributes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first plan answered how to write an env block into a Claude Code settings file and called that the feature. It was Claude-shaped, and it had no AIDD-level switch. Per tool the activation differs and one of them cannot be done by us at all: Claude Code, Codex and OpenCode take a config file the CLI can write, Copilot reads an environment variable with no file behind it, and Cursor's export is a team setting on an Enterprise plan that the framework can check and never set. A plan that does not say which is which gets discovered to be Claude-only by whoever tries the second tool. The switch is the part that was missing. A tool may export telemetry for reasons unrelated to this framework, so keying off "is the provider exporting" would let telemetry enabled for one purpose enrol a project in another. One file AIDD owns, committed so it survives a clone, read by every component at the point of use and never cached, absent meaning off. Also drops a duplicate before it was written: the manifest already records which entries were merged into which file per tool, and clean already removes exactly those. Enabling an export is one more entry in machinery that exists, not a second writer that only one of the two could undo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aidd_docs/ is documentation. Putting configuration there is how both stop being trustworthy, and #585 already specifies .aidd/ as the project config root, committed and host-neutral. This lands in the home already chosen rather than inventing one. JSON rather than #585's YAML, on a constraint rather than a preference: the journal hook ships with zero dependencies, since the build copies hooks/ verbatim with no install step, and the CLI has no YAML parser either. A hook can JSON.parse and cannot parse YAML without something to parse it with, so anything a hook reads is JSON. #585 anticipates this with its config.json fallback and has been told which path telemetry took. Leaves #585 one thing to decide rather than discover: whether a project ends up with both files, and where the boundary between them falls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hes the e2e The Windows job failed on `scripts/__tests__/dev-sync.test.js`, which arrived on `next` an hour ago with #742 and was merged without `cli CI` running on it. Two assumptions in one helper, none of them about what the test is checking: - `spawnSync("/bin/bash", …)` — there is no such path on Windows; bash there is the one Git for Windows ships. A bare `bash` resolves through each platform's own rules and is the same binary on both. - `PATH: ${fakeBin}:/usr/bin:/bin` — a POSIX separator and two directories Windows does not have. It is `path.delimiter` now, and the base is per platform: the strict pair on POSIX, where the isolation matters because a real `opencode` may be installed on the machine running the tests; the inherited `PATH` on Windows, where the shell utilities `dev-sync.sh` calls live wherever Git put them and where putting the stubs first shadows anything real by the same mechanism. Excluding the file on Windows was the other option and is the wrong one: the script it covers is a developer command people run, and a test that only runs on the platform it already worked on proves nothing new. The same job never reached its own e2e step because this failed first — which is why `telemetry-commit-trailer.e2e.test.ts` had never once executed on Windows, despite being in that step's scope all along. It is the only test that makes real commits and reads their messages back, so it is the only thing that proves the `prepare-commit-msg` hook this build installs is reachable by the shell Git for Windows ships. The step now says so, so a future exclusion has to be deliberate. One assumption of my own removed on the way: that e2e spawned `chmod`, a binary reached on Windows only if Git's `usr/bin` happens to be on PATH. `fs.chmod` does the same thing everywhere, and its no-op for the execute bit on Windows is right — git runs a hook there through the shell it ships, not through the file's mode. scripts 317 · typecheck, lint, layering clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…hrough Six commit-trailer e2e tests failed on the Windows runner with `cannot spawn .git/hooks/prepare-commit-msg: No such file or directory`, and the only one that passed was the one that installs no hook at all. That message reads like a missing hook file and is nothing of the sort. Git runs a hook by reading its shebang and looking the interpreter up **by name on PATH**: `#!/bin/sh` sends it looking for `sh.exe`. `pathWithoutAidd` hands every sandboxed run a deliberately narrow PATH — node's own directory, wherever `git` lives, and the platform's system directories. On POSIX that list already carries `/bin`, so the shell is found and the dependency is invisible. On Windows the shell lives in `<git>\bin` and `<git>\usr\bin`, never in the `cmd` directory `git.exe` is usually found in, so there was no shell on that PATH at all. So the hook was correct and installed; the test's own environment could not run it. Nothing about the product changed here. `gitShellDirs` derives those directories from wherever `git.exe` was found and probes them, so an install laid out differently contributes nothing rather than a path that does not exist. The rest is about not repeating this. The branch that broke is one nobody developing here can execute, and a line only a remote runner ever reaches is a line nobody is really maintaining — so the whole list is now computed by a pure `pathDirsWithoutAidd(inputs)` and the Windows shape is asserted from any platform: that it carries a directory holding the shell, that it still carries node, git and System32, and that POSIX gets its shell from `/bin`, which is why this stayed invisible as long as it did. Remove the wiring and one of those goes red. unit 2315 (+7) · integration 668 · e2e 290 · scripts 317 · typecheck, lint, layering clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…sing again Two hypotheses, two runs, no change: the commit-trailer e2e still fails on the Windows runner with `cannot spawn .git/hooks/prepare-commit-msg: No such file or directory`, and only there. Guessing a third time is the wrong move. What is certain: git only spawns a hook it already found, so the file exists; on Windows that message means the interpreter named by the shebang could not be resolved; and the file is written in pure LF by an adapter that normalises nothing. What is not certain is why a shell is still not reachable after the PATH the tests build gained Git for Windows' own `bin` and `usr\bin`. So this step prints the three facts that decide it, on the one platform nobody here can run: the hook's exact bytes (`od -c` — a stray CR after `#!/bin/sh` produces this precise message and nothing else looks like it), where a shell actually lives on that runner, and the PATH the failing tests hand git, reconstructed the way `pathWithoutAidd` builds it. It runs before the suites so it prints whatever they do, and `continue-on-error` so it can never go red — a diagnostic that fails would hide the failure it exists to explain. Temporary, and marked so: it comes out with the fix it is meant to make possible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
… happens to live The diagnostic answered it, and the answer is that nothing was wrong with the product. On the runner, with its own PATH: commit exit: 0 The hook installs, git spawns it, the commit carries its trailer. What failed was the narrowed PATH `pathWithoutAidd` hands the sandboxed runs, which had no shell on it — and git resolves a hook's `#!/bin/sh` by looking `sh.exe` up on PATH, so every hook died with `cannot spawn …: No such file or directory`, a message that reads like a missing hook file and is nothing of the sort. The previous attempt derived the shell from the git directory, one level up, and was measured wrong: git.exe C:\Program Files\Git\mingw64\bin sh.exe C:\Program Files\Git\usr\bin, C:\Program Files\Git\bin Two levels away, on a different branch — so it contributed nothing and the run looked identical. Guessing a layout was the mistake, not the particular guess. `shellDirsWithoutAidd` now keeps the directories already on `PATH` that hold a shell, minus any that also hold `aidd` — the same rule the git directory is chosen by, and the reason a global install cannot readmit the binary these tests exist to prove unnecessary. It needs no theory of the install layout and cannot be wrong about one. The tests are written against the layout the runner actually reported rather than one imagined, including PATH repeating a directory and an npm directory carrying `aidd`. Remove the wiring and one goes red. The diagnostic step comes out in the same commit, as promised when it went in. unit 2316 (+1) · integration 668 · e2e 290 · scripts 317 · typecheck, lint, layering clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
An inventory of everything this branch still describes as open, and a correction where the description was the only thing keeping something open. The reference week printed "the week has counters and no currency until the price table lands (#654)", and its builder said the same. That reads as pending work. It is not: the framework holds no price table by decision, argued from its own boundary clause and confirmed against the destination's backlog, which excludes cost "sans formule de conversion" and owns a dated price reference of its own. Both now say settled rather than pending, and point at the record instead of an issue number. The Codex subagent case was accurately described and unbounded, which reads worse than it is. `session_meta.id` and `session_meta.session_id` on a subagent rollout are the subagent and the thread that delegated to it — so whichever of the two `CODEX_THREAD_ID` carries, the trailer names the same piece of work. The worst reading is a commit attributed to the parent thread rather than the delegated turn inside it: coarser than intended, never another person's session and never a different tree. Where the named rollout has no records read, it is the ordinary "a join that finds no records on the other side" the contract already covers. Stated in both the source and the published contract. Settling it takes a Codex session that delegates, with the variable read from inside the subagent. Nothing forces a delegation from the command line, so it waits for one that happens anyway rather than for a run bought to provoke it. Checked while there: no TODO, FIXME or XXX anywhere in the telemetry surface, and no open issue number left in shipped code or in the published contract. scripts 317 · full suite 3274 · typecheck, lint, knip, jscpd, layering clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The plugin manifest still described the surface deleted in the CLI pivot: "the scripts read what your AI tool already wrote", when the 25 files under skills/*/scripts/ are gone and the three skills reach `aidd telemetry` instead, and "Self-contained", when answering needs the CLI. The marketplace entry said "Ships hooks only, and carries no measurement itself", when the plugin ships three skills. Both are the first sentence a person reads when browsing or installing, so they are the one place the pivot had to land and did not. The plugin README, the root README and docs/CATALOG.md were already correct. Also refreshes the context files the memory hook owns: the decision record added on this branch was never listed in AGENTS.md, CLAUDE.md or the memory README. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…s read first "`report` reads only that" was true about where the figures come from and misleading about what a person has to type: `ReportCostUseCase.catchUp` reads every session the journal names that the store has not caught up with, before it answers, so the journey is `on` then `report`. Read as written, the sentence sent someone to `read` for a figure they would have got anyway, and implied step two returns nothing on a fresh machine. Names what `read` is still for — what each tool answered, one line per tool — rather than leaving it looking redundant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
`cli-ci.yml` filters by directory, so a change to `plugins/aidd-telemetry/README.md` ran all eleven jobs — including the six-minute Windows one — for a paragraph. The markdown there is genuinely asserted (the README's coverage table, and where its scripts say they live), but only by `scripts/__tests__`, which `validate.yml` runs over the whole tree on every push and pull request: unfiltered, required, and on ubuntu. One negative pattern rather than a second workflow or a paths-filter action. The plugin's hooks, skills and manifests still trigger every job, because those the Windows runner does prove something about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
The previous commit's reasoning holds for a push and overstates a pull request. Run 33640120247 settles it: `e526ea7e` changed `.github/workflows/cli-ci.yml` alone — a path in no filter list — and all nine cli jobs ran anyway, Windows included. On `pull_request` GitHub matches the paths against the whole diff, not against the newest commit, and this branch touches cli/ throughout. So the exclusion saves a run on a push to next or main, and on a pull request whose diff is prose and nothing else. It is still worth its one line — the telemetry plugin's docs move on their own often enough — but the comment now says what it does rather than what it would do if pull requests worked the other way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp
…sewhere "Alpha — not ready for use" was the catalogue's own instruction not to use the thing this branch just documented how to use. The plugin measures five tools, each proven by a session that ran, and every gap it has is named beside the figure rather than hidden — that is a beta. Beta here means: usable, and still off the curated install path. What graduates it is having run on machines that are not this one, not the coverage table turning all green — two of its rows are limits of the tool (Cursor writes no token count at all, Copilot writes a session total and never a per-request figure), and no amount of work here changes them. `recommended: false` stays for exactly that reason, so nothing installs it by surprise. `aidd-ui` is untouched: it really is a smoke test with no capability behind it. 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.
Three acts, and they do not need the same things. Allowing measurement and answering what it cost both go through
aidd. Recording needs nothing at all: the hooks are plain zero-dependency node, so a session measured today stays readable by a CLI that was not installed when it ran. Nothing leaves the machine unless you point it somewhere yourself.🛠️ How it works
Three skills:
00-initallows measurement,01-costanswers what a period or a task consumed,02-checksays whether the chain is actually recording. Each callsaidd telemetry …; none ships a script of its own.One contract, five adapters. Each tool's file shape maps to one record through 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, never as a
0that reads as free. Same rule for attribution: every record states its own strength —tool-stated,journal-interval,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 read while still running be superseded later by a strictly better reading of the same turn, rather than frozen partial.
One implementation, not two mirrors. The plugin used to carry its own reporter and its own checker beside the CLI's, kept equal by a parity suite. That second implementation is deleted — 25 files, 4,355 lines — and the parity suite with it. A figure can no longer disagree with itself, because there is only one place that computes it. The hooks stay plain node, because they are the only part that runs per tool call.
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: 37% of output tokens lost, and no test saw it, because 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%;inputand cache-write differ by construction, since/usageweights a 1-hour cache line by its price while a transcript counts it once.🧪 How to verify
At
4986067d: CLI 3,274 across 298 files, plugin 317 across 19, all green; tsc, biome, knip, layering and link checks clean.cli / Windowsinstalls the packed tarball withnpm install -gand then runsaidd --version && aidd telemetry check— a command lifted from02-check's own markdown, so the job fails if the CLI cannot be resolved on the PATH.End to end, on a real repository:
Found by running, not by reading. Each was invisible to a green suite:
unrecognised_payloadis written by the plugin in CommonJS (record.cjs:268) and read by the CLI in TypeScript (telemetry-evidence-adapter.ts:30). Renaming the hook's literal left 11 e2e and 186 plugin tests green: one side asserted only that the marker file existed, the other typed the same literal into its own fixture. With the marker unread, a payload that did arrive reports as "the hook never fired" — an unknown printed as a nothing. Now guarded by a case that spawns the real hook and reads whatever file it writes."type": "commonjs"throughpackage.jsonfiles, and the flat-skill rename moved one away from the scripts it covered. Every CommonJS file it ships is.cjsnow, which Node reads as CommonJS whatever the host declares.hooks/opencode-plugin.jsis the one exception: OpenCode auto-discovers{plugin,plugins}/*.{ts,js}and nothing else — measured, 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.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.One proof can never be re-run.
aidd telemetry checkwas compared verdict-by-verdict against the plugin checker it replaces, on seven scenarios, by restoring the deleted script from git. That checker is gone; this comparison is unreproducible by design.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 in the list below. #703's "a session that resolved no skill is distinguishable from one that needed none" has no implementation. #694's scale bullet was measured against a synthetic tree, not the hundred-session one it names. #631's evidence reads "the same skill proves both"; it is two,
01-costand02-check.#683 closes, with its residual written down rather than hidden. It asked that adding a tool mean writing one file rather than editing five tables in three files. All five are gone as hand-maintained tables:
VENDOR_FIELD_BY_HOST,WRITTEN_PATH_EXTRACTOR_BY_HOSTandSTEP_START_BY_HOSTare nowObject.fromEntries(Object.entries(TOOLS_BY_HOST)…)views over one table, andSESSION_ID_READER_BY_HOSTandCWD_READER_BY_HOSTbecame thereadSessionIdandreadCwdfunctions inhooks/lib/tools/index.cjs, gone under any name. Each host states its own facts inhooks/lib/tools/<host>.cjs, one module each. The residual is one line: a sixth tool means the module plus itsrequireinindex.cjs— two files, not one.host.cjsstill names the five as well, but that is payload sniffing, deciding which tool sent this JSON, and no dispatcher shape removes it. The cost the issue was written against — understanding a tool means reading five places — is gone.Unrelated reorg riding along. 12 files move from
aidd_docs/plans/toaidd_docs/tasks/(R100renames, no content change). Nothing here depends on it; say the word and it comes out.🔗 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.#617 is already closed by hand — it argued that everything which reads belongs to the plugin, and this branch reverses that on purpose; the reversal is written in its own thread.
Closes #617, #630, #631, #649, #659, #661, #676, #680, #681, #683, #686, #688, #693, #695, #697, #698, #699, #700, #701, #702, #704, #705
#630 closes against #746, which carries what it does not prove. The commit trailer works: eight e2e tests make real commits and read the messages back — an agent's commit carries
AIDD-Session-Id, a commit no session made carries nothing, the id is the session actually running rather than one inherited from an environment variable, an amend writes it once,offstops trailering and leaves existing commits alone, and aprepare-commit-msgthe repository already had still runs.Two of its seven boxes are proven by nothing, and both moved to #746 rather than into a closing comment nobody reads. It asks for four host configurations —
core.hooksPath, lefthook, husky, none — and only the last is tested;core.hooksPathis handled in code (resolveHooksDirasks git throughrev-parse --git-path hooks) and asserted nowhere. And its p95-under-200ms box has no timing test.What that second reading turned up is worth more than the missing tests. The install appends one line to
prepare-commit-msg, and in a repository where another tool generates that file the line is erased the next time it regenerates — measurable on this very repository, where lefthook rewrotecommit-msg,pre-commitandpre-pushon 2 September and sparedprepare-commit-msgonly becauselefthook.ymldeclares no job for it.aidd telemetry checkcarries no claim about the trailer at all, so the loss would be silent. #746 owns the repair — re-establish the call site onsession-start, never defend it, so one code path covers a regenerated hook, a hand-made overwrite and acore.hooksPathmove without naming a single third-party tool — and the five claimscheckshould make about it.Every issue in that list was re-verified against the code on 2026-09-02, not inherited from the 2026-08-31 audit — product code moved between the two. What proved each: #659
scripts/probe-identifier-join.cjsprints the attributes it checked and exits 2 for "the probe is broken", behind thecli / Identifier join (Claude Code)job; #661person-resolution.ts, whoseunresolvedrows keep the raw identifier that produced them; #680CURSOR_EVENT_MAPmappingStopto bothstopandsessionEnd; #681 six capturedcopilot-compat-*fixtures beside the canonical ones; #686SYNTHETIC_MODELinclaude-code-transcript.ts, with an e2e asserting no<synthetic>reaches a by-model breakdown; #688 one surviving cast, allowlisted incheck-cli-layering.mjswith its written reason, andcli/testsnow scanned; #693 and #695worktreeFieldsinhooks/lib/repo.cjs, absent rather than empty; #697kind: "session"incopilot-events.ts; #698acceptsHooks: trueon Codex; #699CODEX_HOOKS_TRUST_NOTICEwith its own integration test; #700 commit2343beea; #701 the captured Copilot skill payloads; #704 theNO_KNOWN_PROJECTsymbol; #705byDays. #631 is the epic, and its one unmet criterion is stated above.Two issues were listed here and must not close, re-audited 2026-09-02 against the branch:
#676 was in that block until today and no longer belongs there. Its last box asks that
docs/ARCHITECTURE.mdrecord the second installation mode, as an exception to "hooks are declared inhooks.json" — andc10c2222, on this branch, does exactly that: the OpenCode row nameshooks/opencode-plugin.js, the three events it maps, and says in as many words that the column it sits in is about the declarative axis alone.#649 closes as a rescope, not as written. Its scope asks for a
metadata.jsoncarryingunit_id, an upwardbackloglink,branch,pull_request, and asteps[]journal. Only the upward link is built. The rest was argued redundant against this branch and not merely skipped: the journal already timestampsstep_startandfile_written, so which step produced which file is derivable by the same interval mechanism task attribution uses, and a second file would be a second source of truth that can disagree with the first; the task folder path is already an identity;branchandpull_requestare derivable from git and the forge. Anyone reading #649's scope against the branch will find four of its five fields absent, which is the intended outcome, not a gap.Two more closed separately, because the branch removed their subject rather than satisfying them: #653 (its outcome arrived by the local-read route; the export route it describes was deleted) and #655 (it redacts an upload path that no longer exists — its outbound clause moved to #662).
✅ I certify