Skip to content

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

Merged
blafourcade merged 256 commits into
nextfrom
claude/aidd-telemetry-layer-e403uf
Sep 2, 2026
Merged

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

Conversation

@blafourcade

@blafourcade blafourcade commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

🎯 What & why

Know what a feature cost — tokens, models, time — across Claude Code, Codex, Copilot, Cursor and OpenCode, by reading the files those tools already write.

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-init allows measurement, 01-cost answers what a period or a task consumed, 02-check says whether the chain is actually recording. Each calls aidd 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 in metrics-contract.md. A field not on that list cannot be stored, so no prompt, code or diff ever can be.

An unknown is never a zero. Cursor writes no token count anywhere; Copilot writes a session total and never a per-request figure. Both are reported as what they are, beside the figure, never as a 0 that 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 carrying output_tokens: 3 where the real figure was 329. 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 /usage screen over 15,684 billed calls — output and cache-read agree to ~3%; input and cache-write differ by construction, since /usage weights a 1-hour cache line by its price while a transcript counts it once.

🧪 How to verify

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

At 4986067d: CLI 3,274 across 298 files, plugin 317 across 19, all green; tsc, biome, knip, layering and link checks clean. cli / Windows installs the packed tarball with npm install -g and then runs aidd --version && aidd telemetry check — a command lifted from 02-check's own markdown, so the job fails if the CLI cannot be resolved on the PATH.

End to end, on a real repository:

aidd telemetry on          # allow measurement for this project
# work as usual, then
aidd telemetry report      # what did it cost
aidd telemetry check       # is the chain actually recording

⚠️ Heads-up

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

  • The hook and the CLI shared one string, guarded by nothing. unrecognised_payload is 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.
  • A directory marker does not survive a rename. The plugin declared "type": "commonjs" through package.json files, and the flat-skill rename moved one away from the scripts it covered. Every CommonJS file it ships is .cjs now, which Node reads as CommonJS whatever the host declares. hooks/opencode-plugin.js is the one exception: OpenCode auto-discovers {plugin,plugins}/*.{ts,js} and nothing else — measured, renaming it .mjs made OpenCode find it, log it, and never run it.
  • Cursor never loads a plugin's own hooks.json. Seven declared events, three probes, headless and interactive: not one fired. The project's own .cursor/hooks.json fires normally — the obstacle was where we installed, not Cursor.
  • Codex will not run a hook nobody approved, and says nothing. Trust is per entry, so a renamed event inherits no approval, and a headless run never sees the prompt.
  • os.homedir() never reads $HOME on Windows, it reads USERPROFILE. The plugin resolved HOME || homedir(), the CLI called bare homedir() — so a Windows run read 43,853 tokens from fixtures a POSIX run read 183,939 from.

One proof can never be re-run. aidd telemetry check was 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 run is 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-cost and 02-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_HOST and STEP_START_BY_HOST are now Object.fromEntries(Object.entries(TOOLS_BY_HOST)…) views over one table, and SESSION_ID_READER_BY_HOST and CWD_READER_BY_HOST became the readSessionId and readCwd functions in hooks/lib/tools/index.cjs, gone under any name. Each host states its own facts in hooks/lib/tools/<host>.cjs, one module each. The residual is one line: a sixth tool means the module plus its require in index.cjs — two files, not one. host.cjs still 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/ to aidd_docs/tasks/ (R100 renames, 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 when next is 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, off stops trailering and leaves existing commits alone, and a prepare-commit-msg the 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.hooksPath is handled in code (resolveHooksDir asks git through rev-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 rewrote commit-msg, pre-commit and pre-push on 2 September and spared prepare-commit-msg only because lefthook.yml declares no job for it. aidd telemetry check carries no claim about the trailer at all, so the loss would be silent. #746 owns the repair — re-establish the call site on session-start, never defend it, so one code path covers a regenerated hook, a hand-made overwrite and a core.hooksPath move without naming a single third-party tool — and the five claims check should 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.cjs prints the attributes it checked and exits 2 for "the probe is broken", behind the cli / Identifier join (Claude Code) job; #661 person-resolution.ts, whose unresolved rows keep the raw identifier that produced them; #680 CURSOR_EVENT_MAP mapping Stop to both stop and sessionEnd; #681 six captured copilot-compat-* fixtures beside the canonical ones; #686 SYNTHETIC_MODEL in claude-code-transcript.ts, with an e2e asserting no <synthetic> reaches a by-model breakdown; #688 one surviving cast, allowlisted in check-cli-layering.mjs with its written reason, and cli/tests now scanned; #693 and #695 worktreeFields in hooks/lib/repo.cjs, absent rather than empty; #697 kind: "session" in copilot-events.ts; #698 acceptsHooks: true on Codex; #699 CODEX_HOOKS_TRUST_NOTICE with its own integration test; #700 commit 2343beea; #701 the captured Copilot skill payloads; #704 the NO_KNOWN_PROJECT symbol; #705 byDays. #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.md record the second installation mode, as an exception to "hooks are declared in hooks.json" — and c10c2222, on this branch, does exactly that: the OpenCode row names hooks/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.json carrying unit_id, an upward backlog link, branch, pull_request, and a steps[] journal. Only the upward link is built. The rest was argued redundant against this branch and not merely skipped: the journal already timestamps step_start and file_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; branch and pull_request are 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

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

@blafourcade

Copy link
Copy Markdown
Contributor Author

What to read, and why the file count lies

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

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

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

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

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

Compare view for exactly those: 0509345...1015566

The base needs a decision before this leaves draft

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

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

Everything else is ready

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

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

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

Copy link
Copy Markdown
Contributor Author

Retargeted to next, which is where it should have gone

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

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

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

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

The base decision stands, and it is now smaller

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

Not merged, deliberately

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

blafourcade and others added 17 commits August 22, 2026 11:53
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>
reference-week and others added 10 commits September 2, 2026 09:45
…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
@blafourcade
blafourcade marked this pull request as ready for review September 2, 2026 19:29
@blafourcade
blafourcade merged commit 627408f into next Sep 2, 2026
25 of 26 checks passed
@aidd-bot aidd-bot Bot mentioned this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment