diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 0e8f577ff..62951e940 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -73,7 +73,7 @@
{
"name": "aidd-telemetry",
"source": "./plugins/aidd-telemetry",
- "description": "Measurement: journals every session so a unit of work can be tied to what it cost. The hooks record on plain node; its three skills reach the `aidd` CLI to turn it on, answer and check.",
+ "description": "Measurement: counts the tokens of Claude Code sessions per task, ticket, model and day, locally. The hooks ask for a task on plain node; its two skills reach the `aidd` CLI to turn it on and answer.",
"strict": true,
"metadata": {
"recommended": false
diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml
index 7c94687db..b025c18b9 100644
--- a/.github/workflows/cli-ci.yml
+++ b/.github/workflows/cli-ci.yml
@@ -46,8 +46,7 @@ jobs:
id: filter
run: |
set -euo pipefail
- # The scope the jobs below actually exercise. The telemetry plugin's own prose is
- # excluded because validate.yml already asserts it on every push and pull request.
+ # The scope the jobs below actually exercise.
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
@@ -71,17 +70,15 @@ jobs:
cli/*|kanban/*|scripts/__tests__/*|README.md)
relevant=true
;;
+ # The identifier-join probe, and the hook predicates it judges the real tool by.
+ scripts/probe-identifier-join.cjs|plugins/aidd-telemetry/hooks/*)
+ relevant=true
+ ;;
# This file: a change to how the suite runs matches nothing else here, so the
# workflow deciding whether the suite passes would land unverified.
.github/workflows/cli-ci.yml)
relevant=true
;;
- plugins/aidd-telemetry/*)
- case "$f" in
- *.md) ;; # prose only, covered by validate.yml instead
- *) relevant=true ;;
- esac
- ;;
esac
[[ "$relevant" == "true" ]] && break
done < <(git diff --name-only "$BASE" "$HEAD")
@@ -412,19 +409,17 @@ jobs:
restore-keys: pnpm-${{ runner.os }}-
- run: cd cli && pnpm install --frozen-lockfile
- run: cd cli && pnpm build
- # The probe drives the real tool, so the real tool has to be here. No credentials are
- # needed: it points Claude Code at a dead address, and the session id is minted first.
+ # The check drives the real tool, so the real tool has to be here. No credentials are needed.
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code # floats on purpose, declared in scripts/__tests__/workflows-pin-global-installs.test.js
- # Everything in this layer joins on the identifier a hook receives being the one the
- # export carries, and a tool update can break it with nothing else turning red. Exit 2
- # means the probe could not form an opinion, rather than blaming the tool.
- - name: Probe the identifier join
- run: node scripts/probe-identifier-join.cjs
# The golden snapshots pin what a claude build contains; only the host can say it loads.
# Its `plugin validate` exits 0 either way, so the script reads the verdict from its text.
- name: Claude Code accepts the translated marketplace
run: node scripts/check-claude-accepts-build.cjs
+ # One session id in the hook's payload, its environment and the transcript: what the
+ # telemetry joins on. Against a dead API, so it spends nothing.
+ - name: Claude Code keeps the session id join
+ run: node scripts/probe-identifier-join.cjs
cli-jscpd:
name: cli / JSCPD (duplication)
@@ -489,38 +484,9 @@ jobs:
with:
node-version: "22"
- # The WRITE path, on the platform where a home directory, a line ending and an absolute
- # path all resolve differently. It runs before the CLI is built on purpose: recording is
- # supposed to need nothing installed, and this is where that claim is exercised.
- - name: Chain - allow measurement, with nothing installed
- run: |
- # Written directly rather than through a command: the switch is a file the hooks
- # read fresh at every write, so no binary has to exist for it to work.
- mkdir -p .aidd
- node -e "require('fs').writeFileSync('.aidd/config.json', JSON.stringify({ telemetry: { enabled: true } }, null, 2) + '\n');"
- - name: Chain - journal a captured payload
- run: |
- # The fixture's cwd came off whatever machine captured it: rewritten so getRepoRoot
- # resolves a repository that exists here.
- node -e "const p=require('./scripts/__tests__/fixtures/claude-code-session-start.json'); p.cwd=process.cwd(); require('fs').writeFileSync('payload.json', JSON.stringify(p));"
- node plugins/aidd-telemetry/hooks/journal.cjs session-start < payload.json
- node plugins/aidd-telemetry/hooks/journal.cjs turn-end < payload.json
- # `telemetry check` fails on `sessionFound: false`, and nothing in this chain places a
- # transcript for the session just journalled. Copied from a local-cost fixture with its
- # session id rewritten, under `$HOME` โ which is what the read path itself resolves
- # first, and which Git Bash sets on this runner.
- - name: Chain - place the transcript where claude keeps it
- run: |
- node -e "const fs=require('fs'),os=require('os'),path=require('path');const homeDir=process.env.HOME||os.homedir();const sessionId='ffde6fda-14a8-4b32-8110-be1f1d13eebf';const src='cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222.jsonl';const dir=path.join(homeDir,'.claude','projects','fake-project');fs.mkdirSync(dir,{recursive:true});const content=fs.readFileSync(src,'utf8').split('22222222-2222-4222-8222-222222222222').join(sessionId);fs.writeFileSync(path.join(dir,sessionId+'.jsonl'),content);"
- - name: Chain - the journal stays private and git add -A still works
- run: |
- git add -A
- git status --porcelain >/dev/null
-
# Wrapped: a suite writing into this repository's own .git/hooks passes every assertion
# and destroys an install nothing can restore. The suite reads YAML through the root
- # devDependencies, so the install has to precede it โ and the chain steps above stay
- # ahead of any install on purpose.
+ # devDependencies, so the install has to precede it.
- run: pnpm install --frozen-lockfile
- run: cd cli && pnpm install --frozen-lockfile
- name: Plugin suite
@@ -554,21 +520,13 @@ jobs:
pnpm build
pnpm pack --pack-destination ./dist
npm install -g ./dist/ai-driven-dev-cli-*.tgz --force
- # The exact command `02-check`'s own markdown names, not a script beside it. Failing
- # `aidd --version` means the CLI could not be resolved on the PATH.
- - name: Chain - diagnose, through the command every skill's own markdown names
- run: |
- aidd --version
- # A gated run judges nothing and exits 0, so the one row the chain exists to make
- # true is asserted separately: the transcript placed for the session was read.
- aidd telemetry check | tee telemetry-check.out
- grep -E "tool files readable +ok" telemetry-check.out
+ # Failing `aidd --version` means the CLI could not be resolved on the PATH.
+ - name: Chain - the installed CLI resolves
+ run: aidd --version
- run: cd cli && pnpm test:unit
- run: cd cli && pnpm test:integration
- run: cd cli && pnpm test:arch
- # Every e2e but the two named below. `telemetry-commit-trailer.e2e.test.ts` is why the
- # step matters most: it is the only thing proving the `prepare-commit-msg` hook is
- # reachable by the shell Git for Windows ships, which a backslash path is not.
+ # Every e2e but the one named below.
- name: cli e2e
# `--max-workers=2`, and only here. Vitest transforms modules on its own main thread
# while every worker calls back into it, and that call has a fixed 60 s timeout no
@@ -578,11 +536,8 @@ jobs:
run: |
cd cli
pnpm exec vitest run --project=e2e --max-workers=2 \
- --exclude "tests/e2e/persona.e2e.test.ts" \
- --exclude "tests/e2e/telemetry-multi-tool.e2e.test.ts"
- # persona.e2e.test.ts needs /usr/bin/expect, which windows-latest does not carry;
- # telemetry-multi-tool.e2e.test.ts relies on a shebang, and Windows resolves an
- # executable by PATHEXT.
+ --exclude "tests/e2e/persona.e2e.test.ts"
+ # persona.e2e.test.ts needs /usr/bin/expect, which windows-latest does not carry.
# Fan-in: every job above either ran and passed, or was skipped because `changes` found
# nothing relevant. The one check the branch rulesets require โ without it a pull request
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 866b01013..91bdb9561 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -52,11 +52,11 @@ jobs:
run: pnpm exec lefthook run pre-commit --all-files --force
# The generator hooks `git add` their output and never fail on drift, so a stale
- # CATALOG.md, prompts doc or README count passes the step above in silence. Against
+ # CATALOG.md or README count passes the step above in silence. Against
# HEAD, not the index: the generators already staged, so a plain `git diff` is empty.
- name: Fail if a generated file drifted from what pre-commit regenerated
run: |
- if ! git diff --exit-code HEAD -- 'plugins/*/CATALOG.md' docs/prompts-documentation.md README.md; then
+ if ! git diff --exit-code HEAD -- 'plugins/*/CATALOG.md' README.md; then
echo "::error::A generated file is stale. Run 'pnpm exec lefthook run pre-commit --all-files --force' locally and commit the result."
exit 1
fi
diff --git a/.gitignore b/.gitignore
index 8ce3232e3..dd4852527 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,20 +34,11 @@ coverage/
# AIDD CLI's own local state (install manifest, auth): machine-local, never
# part of the project's own tracked content. config.json is the exception:
-# it is the committed telemetry switch (see .aidd/config.json and
-# aidd_docs/tasks/2026_08/2026_08_20_telemetry-export-enable/phase-1.md),
-# tracked so a fresh clone inherits the project's decision.
+# it is the committed project switch (see .aidd/config.json), tracked so a
+# fresh clone inherits the project's decision.
.aidd/*
!.aidd/config.json
-# AIDD run-journal records: where they land once .aidd/config.json turns
-# telemetry on (see plugins/aidd-telemetry/hooks/journal.js) - this
-# directory being committed is a location, not a permission. The records it
-# holds never are.
-aidd_docs/runs/*
-!aidd_docs/runs/.gitkeep
-!aidd_docs/runs/README.md
-
# SpecStory captures (may contain transcripts / secrets)
.specstory/
diff --git a/README.md b/README.md
index 362d9c5e1..ef30eb62d 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best
๐งฑ **IDE agnostic** ยท ๐๏ธ **Legacy systems** ยท ๐ฑ **Token-optimized** ยท ๐ซ๐ท **Made in France**
[](https://opensource.org/)
@@ -315,9 +315,9 @@ UI / UX design โ smoke-test only, not ready for use.
### ๐ [aidd-telemetry](plugins/aidd-telemetry/README.md) ๐งช
-`3 skills` ยท **beta**
+`2 skills` ยท **beta**
-Answers what a piece of work cost โ tokens, models, and which skill spent them. The switch is git-tracked, so it applies to everyone who clones; opt out per person with `AIDD_TELEMETRY=0`. Nothing leaves your machine.
+Counts the tokens of Claude Code sessions per task, ticket, model and day. Opt-in, and local to your machine.
diff --git a/aidd_docs/memory/architecture.md b/aidd_docs/memory/architecture.md
index 7ba45d316..e8ae0d0aa 100644
--- a/aidd_docs/memory/architecture.md
+++ b/aidd_docs/memory/architecture.md
@@ -33,7 +33,7 @@ flowchart LR
| Concern decides placement, not existence | a missing capability goes to the plugin whose concern owns it; the caller delegates |
| A skill is a router | `SKILL.md` dispatches to actions or protocols; the only place a capability is addressed by name |
| Recipe skills discover providers at runtime | by description matching. Only agent permission lists and orchestration references name a provider |
-| Observation is its own layer | `aidd-telemetry` journals what a session did; it never reads or writes application source |
+| Observation is its own layer | `aidd-telemetry` counts what a session consumed, from the task a person declared; it never reads or writes application source |
| A launcher runs an external binary, never embeds it | `kanban` broke that and was unwired. Detail in the CLI bank |
The concern-to-plugin taxonomy is canonical in [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md).
@@ -42,4 +42,4 @@ The concern-to-plugin taxonomy is canonical in [`docs/ARCHITECTURE.md`](../../do
- 9 plugins ship, 3 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in, `aidd-qa` new and unproven outside this repository.
- A skill never links outside itself: the tree ships both flat and as a marketplace, so no relative path survives both.
-- Bundled hooks run Node. No `node` on `PATH`, no memory refresh and no run journal.
+- Bundled hooks run Node. No `node` on `PATH`, no memory refresh and no question about the task.
diff --git a/aidd_docs/memory/cli.md b/aidd_docs/memory/cli.md
index 0f7a0dce5..426453040 100644
--- a/aidd_docs/memory/cli.md
+++ b/aidd_docs/memory/cli.md
@@ -7,8 +7,8 @@ The `aidd` binary, built from `cli/` and published to npm as `@ai-driven-dev/cli
## Commands
- Eleven groups: `setup`, `framework`, `translate`, `plugin`, `marketplace`, `auth`, `sync`, `update`, `doctor`, `clean`, `telemetry`.
+- `telemetry` counts the tokens of Claude Code sessions locally: [usage contract](../product/usage-contract.md).
- No list here. The surface moves; `aidd --help` is the only reading that stays true.
-- `telemetry`'s sink, run journal, record shapes, report axes and per-tool declarations: [`cli/aidd_docs/memory/telemetry.md`](../../cli/aidd_docs/memory/telemetry.md).
## Interface
diff --git a/aidd_docs/memory/codebase-map.md b/aidd_docs/memory/codebase-map.md
index 5a93a6a1b..229ec0d28 100644
--- a/aidd_docs/memory/codebase-map.md
+++ b/aidd_docs/memory/codebase-map.md
@@ -38,7 +38,6 @@ flowchart TD
| Binary | `cli/src/cli.ts` โ `dist/cli.js`, bin name `aidd` |
| Workflow | `plugins//skills/-/SKILL.md` |
| Memory refresh | `plugins/aidd-context/hooks/update_memory.js`, on `SessionStart` |
-| Run journal | `plugins/aidd-telemetry/hooks/journal.cjs`, on `SessionStart`, `Stop`, `PostToolUse` |
## Packages
diff --git a/aidd_docs/memory/coding-assertions.md b/aidd_docs/memory/coding-assertions.md
index 03baec882..5fffc8dfd 100644
--- a/aidd_docs/memory/coding-assertions.md
+++ b/aidd_docs/memory/coding-assertions.md
@@ -29,7 +29,7 @@ Never state in a commit message or a report anything not just observed in output
| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, cross-plugin orthogonality and router coherence (`scripts/check-architecture-rules.js`), context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit โ see below |
| 2 | `pnpm exec commitlint --edit` | the message against `commitlint.config.cjs` |
-Same hook regenerates each plugin's `CATALOG.md`, the README counts and `docs/prompts-documentation.md`, and stages them.
+Same hook regenerates each plugin's `CATALOG.md`, and the README counts, and stages them.
Every `cli` job is globbed on `cli/**`. A change under `kanban/` alone fires no local job; run `cd kanban && pnpm test` by hand. CI does cover it โ `cli-ci.yml`'s `kanban-checks`, see `deployment.md`.
diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md
index 901f9cdd9..7a65597c2 100644
--- a/aidd_docs/memory/deployment.md
+++ b/aidd_docs/memory/deployment.md
@@ -9,7 +9,7 @@ Where the project runs and how it ships: CI/CD, environments, and release.
| Workflow | Runs |
| --- | --- |
| `ci.yml` | commitlint on pull requests and on `main`'s tip, plus the PR title itself โ the subject a squash merge uses โ then release-please on `main` and the release jobs |
-| `cli-ci.yml` | the `cli` and `kanban` gates โ job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do โ `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose. Mutations skip only for a same-repository numeric `promote/next-to-main-*` snapshot whose `cli / gate` passed in a successful `next` push and whose PR merge tree equals that snapshot with `main` already its ancestor; the resulting `main` push reuses it only for that exact two-parent promotion merge when its tree, associated merged PR, and source snapshot all match. Missing, failed, unreadable, or mismatched Git/API proof keeps normal mutation scopes. All non-mutation checks still run on the current PR merge ref or `main` commit. |
+| `cli-ci.yml` | the `cli` and `kanban` gates โ job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do โ `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself. Mutations skip only for a same-repository numeric `promote/next-to-main-*` snapshot whose `cli / gate` passed in a successful `next` push and whose PR merge tree equals that snapshot with `main` already its ancestor; the resulting `main` push reuses it only for that exact two-parent promotion merge when its tree, associated merged PR, and source snapshot all match. Missing, failed, unreadable, or mismatched Git/API proof keeps normal mutation scopes. All non-mutation checks still run on the current PR merge ref or `main` commit. |
| `validate.yml` | plugin and marketplace manifests against their schemas, plus the whole pre-commit over the whole tree |
| `codeql.yml` | code scanning |
| `promote.yml` | opens the `next` to `main` promote PR, merge auto-merge |
diff --git a/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md b/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md
index d650edbd4..ee0ee0529 100644
--- a/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md
+++ b/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md
@@ -15,7 +15,7 @@ Both were written before anything was built. Measured against what now ships, th
**The privacy clause is honoured, and exceeded.** Measurement is off until a project switches it on; the identifier a person attaches is a random value they opt into and is never derived from a git author, an address or a hostname; no code, prompt or diff reaches storage, verified against every stored record on the machine that built this. Beyond what #297 asked: a person can refuse in their own environment, and that refusal wins over whatever a repository's committed file says.
-**The standard clause is contradicted on all three of its statements.** The sink is a directory of JSON Lines written by `aidd telemetry read`, not an OTel collector. OpenTelemetry is not the route: an OTLP receiver existed, produced no record on the machine that built it across 34 stored records, and was deleted along with the only path that ever sent an address off the machine. And a hosted product โ Le Gouvernail โ is being built, which "No SaaS" forbids outright.
+**The standard clause is contradicted on all three of its statements.** The sink is a directory of JSON Lines written by the measurement itself, not an OTel collector. OpenTelemetry is not the route: an OTLP receiver existed, produced no record on the machine that built it across 34 stored records, and was deleted along with the only path that ever sent an address off the machine. And a hosted product โ Le Gouvernail โ is being built, which "No SaaS" forbids outright.
Issue #660 was opened to resolve this. Its body quotes only the privacy clause, and every one of its completion conditions speaks only to privacy, so it could close with the standard clause left standing and contradicted. It also failed once as a gate: #661 shipped while #660 was open, and nothing stopped it.
@@ -26,7 +26,7 @@ Issue #660 was opened to resolve this. Its body quotes only the privacy clause,
**The standard clause is superseded.** In its place:
1. **There is no single standard transport.** The shipped route reads the files each AI tool already wrote. OpenTelemetry is one shape a destination may speak, not the framework's own.
-2. **The local store is the sink.** A directory of JSON Lines under the person's own profile, whose contract is published in `aidd_docs/product/metrics-contract.md`.
+2. **The local store is the sink.** A directory of JSON Lines under the person's own profile, whose contract is rewritten with the replacement measurement.
3. **Measurement may reach a hosted destination.** "No SaaS" no longer holds.
4. **The framework exposes; the destination analyses.** Aggregating across people and repositories, saved views, filters and anything pushed to someone belong to a hosted destination. What runs on a machine stays deliberately light: it records, it exposes a documented record, and it answers a small number of direct questions through its skills. This is a boundary, not a staging order โ local does not grow into an analytics product while waiting for one.
@@ -56,7 +56,7 @@ Issue #660 was opened to resolve this. Its body quotes only the privacy clause,
- **`aidd telemetry connect` gets a specification it can be written against**: authenticate to a destination the person names, and bind this machine's identity to an account there. Until now it had a purpose but no boundary.
- **The deletion is not reversed.** Nothing here restores a listener or an export writer. What ships today opens no port and sends nothing; a destination is the next thing to build, deliberately, with its own consent.
- **An amount in currency still has no route.** That is #654's price table, unaffected either way by this decision.
-- **The local report is already past "light", and this record says so rather than pretending otherwise.** It carried seven axes when this decision was written; read `ARTEFACT_AXES` in `cli/src/presentation/display/cost-report-artefact.ts` for the current list rather than a count here, which only decays as the surface grows. Nothing here requires removing any of them; what it does require is that growth stops happening for free. A new axis needs an argument for why a machine must answer that question, not merely that it could.
+- **The local report is already past "light", and this record says so rather than pretending otherwise.** It carried seven axes when this decision was written; read `ARTEFACT_AXES` in the previous cost report display (removed with the previous telemetry; see git history) for the list as it stood rather than a count here, which only decays as the surface grows. Nothing here requires removing any of them; what it does require is that growth stops happening for free. A new axis needs an argument for why a machine must answer that question, not merely that it could.
- **#720's task axis already shipped** (`task` is in `ARTEFACT_AXES` today, alongside others this record did not anticipate โ `agent`, `prompt`, `backlog`, `flow`). Under clause 4 that is a destination's question unless someone can say why a single machine must answer it. This record's caution did not gate that landing; reconciling the two is for whichever issue re-litigates this boundary, not a silent rewrite here.
- **#656 lands squarely on the destination side.** Per person, per team, per epic, across repositories โ that is the analysis this boundary assigns to a hosted destination, and it should be re-scoped there rather than pursued as local work.
- **What the framework owes in exchange is a clean exposure.** The record contract, the sink's shape and their versioning stop being internal documentation and become the interface a destination is written against. They now deserve the care an API gets.
diff --git a/aidd_docs/memory/project-brief.md b/aidd_docs/memory/project-brief.md
index 80099b5b7..dc2bb5f41 100644
--- a/aidd_docs/memory/project-brief.md
+++ b/aidd_docs/memory/project-brief.md
@@ -26,7 +26,7 @@ What this project is, the problem it solves, and its domain language. The non-de
| Memory | the bank under `aidd_docs/memory/`, loaded every session |
| Marketplace | `.claude-plugin/marketplace.json`, the plugin registry |
| Concern | what a plugin owns; decides where a capability lives |
-| Run journal | what a session did, appended by a hook under `aidd_docs/runs/` |
+| Usage record | one billed model call, counted once, attributed to the task a person declared |
| Promote | sending `next` to `main`, which opens the release |
## Key features
@@ -43,4 +43,4 @@ What this project is, the problem it solves, and its domain language. The non-de
| Acceptance QA evidence | `aidd-qa:01-acceptance-qa` |
| Refine input and output | `aidd-refine` โ brainstorm, challenge, blind spots |
| End-to-end orchestration | `aidd-orchestrator:01-sdlc` |
-| Measure what a session cost | `aidd-telemetry`, opt-in, plus `aidd telemetry` |
+| Count what the work cost in tokens | `aidd telemetry`, `aidd-telemetry:00-init`, `aidd-telemetry:01-usage` |
diff --git a/aidd_docs/product/cost-report-contract.md b/aidd_docs/product/cost-report-contract.md
deleted file mode 100644
index 52b8bf629..000000000
--- a/aidd_docs/product/cost-report-contract.md
+++ /dev/null
@@ -1,694 +0,0 @@
-# Cost report contract
-
-**Read this if you are writing a skill, or anything else that reports on AIDD work.**
-It describes what `aidd telemetry report --json` prints: one object, the same shape
-whatever tool did the work, carrying both the figures and a statement of what each tool
-could and could not supply.
-
-> If instead you are building a **pricing service or an aggregator** that consumes stored
-> records directly, read [`metrics-contract.md`](./metrics-contract.md) โ the contract for
-> one stored line. The two are deliberately different audiences, and picking the wrong one
-> is expensive: the record contract makes you responsible for the three double-count rules
-> (the two record kinds, a local re-read โ including correcting, never summing, a still-open
-> turn a later read completes โ and one billed call once seen by both an export and a local
-> read at once, for a stored line old enough to predate the export route's removal), and
-> re-read deduplication. This one has already applied all four.
-
-**Never reconstruct these figures from stored records.** One computation in one place is
-the whole point: two ways of computing a number is how they start disagreeing.
-
-## Getting the object
-
-```bash
-aidd telemetry report --json
-aidd telemetry report --from 2026-08-01 --to 2026-08-31 --json
-aidd telemetry report --task 2026_08/2026_08_21_cost-reporter --json
-aidd telemetry report --project acme/widgets --step aidd-dev:02-implement --json
-```
-
-Prints one JSON object on stdout and exits `0`, including when the period holds nothing.
-A period that is not a period โ `--from notaday`, `--days 0` โ exits `1` naming the flag.
-A filter naming a value nothing ever recorded still exits `0` โ see **Filters** below;
-only a malformed period is a usage error.
-
-## Filters
-
-Six dimensions exist: day, project, task, step, model, tool โ an axis says how to
-*group*, a filter says what to *keep*, and every one of them works as either. The period
-(`--from`/`--to`/`--days`) is the day filter; `--task` already existed; `--project`,
-`--step`, `--model` and `--tool` are the other four, each optional:
-
-```bash
-aidd telemetry report --project acme/widgets --json # this project, whole period
-aidd telemetry report --project acme/widgets --step aidd-dev:02-implement --json
-```
-
-**Filters compose by `and`, never by a query language.** Two given narrow to their
-intersection; there is no `or` and no parentheses โ the moment a report needs one it has
-stopped being a report. Filtering and grouping on the same dimension (`--project X` next
-to a `by_project` breakdown that then holds one row) is a legal, boring answer, not an
-error.
-
-**"Axis" above means a breakdown, not a flag.** Every `by_*` array is always present in
-the `--json` object, whatever filters were given โ grouping by any of the six dimensions
-needs no separate flag; reading the matching array is the axis. `by_task` groups by the
-same declared intervals `--task` filters on, never by a session's whole-session
-written-file inference โ see **`by_task`** below. `by_person` is a seventh axis with no
-matching filter flag at all โ see **`by_person`** below โ grouping by who ran the work,
-never a way to keep only one person's records. `by_backlog` is an eighth, also with no
-filter of its own โ see **`by_backlog`** below โ regrouping `by_task`'s own rows one level
-up, by what each task's folder declares. `by_flow` is a ninth, also with no filter of its
-own โ see **`by_flow`** below โ grouping by which orchestrated run the journal's own step
-sequence names, never a second capture. `by_agent` is a tenth, also with no filter of its
-own โ see **`by_agent`** below โ grouping by which agent ran, the main thread carrying its
-own row rather than an absence, and a tool that names no agent at all carrying a third. `by_prompt` is an eleventh, also with no filter of its own โ
-see **`by_prompt`** below โ grouping by the prompt that caused the work, the one breakdown
-no host limit can leave empty. `aidd telemetry report` also takes
-`--axis ` (`total`, `day`, `step`, `model`, `agent`, `prompt`, `task`, `backlog`,
-`flow`, `tool`, `project` or `person`), which picks one of those arrays and renders it alone as a small
-pasteable artefact instead of the whole object โ a convenience for copying one figure out,
-not a second way to group. Every figure `--axis` can show is already in the plain `--json`
-object; only the one-artefact-at-a-time rendering is what it adds. A name outside the twelve
-is a usage error naming the valid list (`Error: Unknown axis 'bogus'. Expected one of:
-total, day, step, model, agent, prompt, task, backlog, flow, tool, project, person.`, exit
-`1`), not a silently empty artefact. Given both flags at once, `--json` wins and `--axis` is
-ignored, never the reverse.
-
-**A filter matching nothing names itself**, in `empty_selection`, rather than the object
-quietly reporting the same shape a genuinely idle period would:
-
-```jsonc
-"empty_selection": { "filter": "project", "value": "acme/ghost", "known": false }
-```
-
-`known: false` means no record this call could see ever carried that value โ a project
-nobody ever worked in. `known: true` means the value is real, just idle in this selection;
-an optional `"combination": true` alongside it means the value matches something on its
-own, and it is the intersection with an already-applied filter that emptied the
-selection, not the value itself. `empty_selection` is **never** present for a period that
-is genuinely idle โ that case is a row of zeros, because the zero is true; this field
-exists only for the different case, where a filter is what emptied it.
-
-The known/unknown distinction is only as good as what a call could still see: a value
-whose every record has since rotated out of the sink reads as `known: false`, the same as
-one that never existed. It answers "did anything I can still read ever carry this", not
-"did this ever happen".
-
-**A model filter always drops a whole-session figure; a step filter usually does.**
-`active_time_s` and a tool's `session_totals` come from `kind: "session"` records, and
-those never carry a `model` โ no reader stamps one on a session-kind record, on any tool
-measured so far. Filtering by model is correct to exclude them: a model selection cannot
-speak to a whole-session figure no model was ever attached to. A `step` is different: a
-session record still gets one wherever its own moment happens to fall inside a journal's
-`step_start` interval, the same attribution every other record gets โ so a step filter
-keeps a session record when that interval matches, and drops it otherwise. Either way the
-number does not appear as `0` when dropped; it is simply absent, the same convention every
-other "never observed" quantity in this object uses.
-
-Adding `filters` and `empty_selection` was not a `cost_report_version` bump: a consumer
-built against version 2 that never passes a filter never sees either field, and neither
-changes what any field it already understood means.
-
-## Determinism
-
-**The same files and the same absolute period produce byte-identical output.** That holds
-across repeated calls and across the order records happen to sit in on disk, which differs
-between machines because a re-read appends.
-
-It does **not** hold for `--days`, which resolves against today. `--days` is the human
-shorthand; anything that stores or compares a figure should ask for `--from` and `--to`.
-The object always reports the period **as it resolved**, absolutely, never as it was asked
-for โ so a figure taken from a `--days` call can still be cited by the days it covered.
-
-## Versioning
-
-`measurement_enabled` was added without a bump: a consumer built against version 4 that
-never reads it sees every field it already understood mean exactly what it already meant โ
-"adding a field you may ignore" is the rule below, applied to the case that motivated
-writing it down. It says whether this project's own switch is on right now; the sink the
-figures above come from is scoped to this person, not to this project, so `false` beside a
-real count is the ordinary case of reporting from a project whose switch never covered that
-work, never a contradiction (see "Attributing records to a task" for the same scope split
-elsewhere in this object).
-
-Every object carries `cost_report_version`, currently `15`.
-
-Bumped from `14` when `by_person`'s `resolution` gained a fourth value,
-**`"this-machine"`**: the record carried no identifier of its own, and this machine has
-declared an identity. It exists because `person_id` is written onto a record when the record
-is *stored*, so whether one carries it depends on when the identity was declared relative to
-when that record was read โ never on the work. Measured on a live machine: 29,207 requests,
-every one read by that machine's own `aidd`, and `by_person` could name none of them; the
-same sink, the same identity file, answered differently depending on the order those two
-things happened in. Declaring an identity now names the whole history rather than only what
-follows. Sound because the sink has exactly one writer and every line it holds carries
-`provenance: "local-read"` โ a record in it was read by this machine's own reader. A consumer
-that switched exhaustively on the three previous values must add this one; every row's
-`totals` still reconciles to the period total.
-
-Bumped from `13` when the reasons a `by_task` or `by_backlog` row can carry gained a
-fifth, **`"precedes-journal"`**: this record's moment is older than the earliest moment its
-own session's journal witnessed. Until then those records read `"precedes-declaration"`,
-which asserts that the work named its task late โ and measured on 2026-09-04, 96.2% of a
-real period (28,570 of 29,207 requests) read that way while fewer than one in five hundred
-of them had actually declared late. The population is ordinary, not an anomaly: reading a
-resumed transcript stores the turns it inherited under the session that read them, dated
-when each was *billed*, days before that session opened a journal. Keyed on the journal's
-own earliest witnessed moment, the same field the `"inferred"` bound above already reads,
-never on its `session_start` line. Decided before `"no-declaration"`, so a journal that
-declared nothing *and* did not cover the record is named by the coverage fact. A consumer
-that switched exhaustively on the four previous reasons must add this one; every row's
-`totals` still reconciles to the period total.
-
-Bumped from `12` when **`by_prompt`** joined the top-level breakdowns โ the prompt that
-caused the work, grouped by the turn each record came from. It is the one breakdown no host
-limit can leave empty: every record the reader stores already carries its own turn, where
-`by_step` depends on a host naming a skill and reads a few percent on a session whose real
-cost sits in subagents. A row with no `prompt` is a record whose turn nothing named, placed
-last like every other axis' remainder rather than dropped. A consumer that summed every
-breakdown's `requests` against `totals.requests` has one more breakdown to sum.
-
-Bumped from `11` when **`by_task`'s `attribution` stopped being always `"declared"`**. A
-record no declaration covers is now named after the one task folder its session wrote into,
-marked `"inferred"`, so one task can hold two rows โ one per route โ the same
-`(name ร attribution)` shape `by_task`'s neighbour `by_step` has always had. A consumer that
-read `attribution` as a constant, or `by_task` as one row per task, misreads this version;
-summing every row's `totals` still reconciles to the period total. Two bounds make the route
-sound and both are load-bearing: a session that wrote into **two** task folders infers
-nothing, since there is no reason to choose between them; and a record outside the span its
-own journal witnessed infers nothing either โ measured, a session whose journal was lost and
-recreated witnessed four minutes while the sink held its records from seven days back, and
-without that bound all seven days would have been attributed to a folder it touched today.
-Nothing new is captured for this: `file_written` was already written by the hook at turn end.
-
-Bumped from `10` when the reasons a `by_task` or `by_backlog` row can carry gained a
-fourth, **`"no-journal"`**. It separates a fact about the read from a fact about the work:
-until then, a record whose session had no usable run journal was given `"no-declaration"`,
-which asserts that the session declared no task. Measured on
-2026-09-04, running the report from a subdirectory of the repository put 100% of a period
-into `"no usable task declaration in this session"` while every journal sat one directory
-up, unread โ the reader anchored at the process working directory and the hook that writes
-a journal anchors at the repository root. The reader now anchors where the writer does, and
-this reason covers what the anchor cannot reach: a project whose journals are on another
-machine, a period read outside any checkout, a session whose journal was removed. A
-consumer that switched exhaustively on the three previous reasons must add this one; every
-row's `totals` still reconciles to the period total exactly as before.
-
-Bumped from `12` when **`by_prompt`** joined the top-level breakdowns. It is the only
-breakdown no host limit can empty: every other one depends on a capture that may not have
-happened โ a run journal, an identity file, a task declaration, a host that names the skill
-it is running โ while this one depends on a field the transcript reader resolves for itself,
-by walking `parentUuid` back to the turn that caused the work. That is not the same as
-complete: measured 2026-09-05 on one machine's sink, 845 of 30,714 records carry no
-`prompt_id`, and all but one of them were stored by a reader that predates the resolution โ
-see **`by_prompt`** below. Nothing new is captured for it.
-A row carries `started_at`, the earliest moment in that prompt, because a prompt id alone is
-opaque โ it is what a person greps for in their own transcript. The row for records that
-named no prompt carries neither `prompt` nor `started_at`, is placed last rather than ranked
-among the prompts, and holds the whole period on every tool but Claude Code, which is the
-truth for a route whose files cannot say which turn caused what. A consumer summing every
-breakdown's `requests` against `totals.requests` has one more to include.
-
-Bumped from `9` when **`by_agent`** joined the top-level breakdowns. It exists because that
-is where the spend is: measured on a live session, ten subagent transcripts held 432M of its
-466M tokens, and every one of their lines names its agent (`attributionAgent`, 100% of
-subagent tokens) where almost none names a skill (2.7%). That is why `by_step` can read a
-few percent while a session's real cost sits elsewhere โ the host names a skill on the main
-thread alone, and no reader can invent one. `by_agent` needs no new capture: `agent_name` was
-already on the record. Every row carries `attribution`, and it is what tells the two rows
-that name no agent apart: `main-thread` is a tool that names agents saying this record
-belongs to none of them โ a session starts there, and it is never "no agent" โ while
-`not-stated` is a tool whose route never names one. Only Claude Code's route does today, so
-on Codex, Copilot and OpenCode every record joins the `not-stated` row. It used to join the
-main thread's, which asserted of those tools a fact nothing observed.
-
-Bumped from `8` to `9` when `attribution` gained a fourth value, `prompt-matched`.
-
-Bumped from `7` to `8` when `by_flow`
-joined the top-level breakdowns (a consumer summing every breakdown's `requests` against
-`totals.requests` now has a seventh breakdown to include). `by_flow` groups by which
-orchestrated run the journal's own step sequence already names โ no skill declares that it
-orchestrates, so which ones do is a fact this object's own reader declares once, never
-matched from a plugin name โ and needs no new capture: the same `step_start` lines
-`by_step` already reads are read again, between whichever of them the declared set names.
-Bumped from `6` when `by_backlog` joined the top-level breakdowns (a consumer summing every
-breakdown's `requests` against `totals.requests` now has a sixth breakdown to include).
-`by_backlog`
-regroups `by_task`'s own per-record membership one level up, by what each task's own
-folder declares (`aidd_docs/tasks//backlog-link.json`) โ never a second notion of
-which task a record belongs to, and resolved once per task rather than once per record.
-Bumped from `5` when the row
-`by_task` gives for a record that fell in no declared interval stopped being a single row
-and became up to three, one per `reason` actually present in the period
-(`"no-declaration"`, `"precedes-declaration"`, `"journal-silent"`, joined at version `11`
-by `"no-journal"` โ see "Attributing records to a task" and the `by_task` section
-below). A consumer that read "the one row with no `task`" as a single, whole-period fact
-would misread this version; summing every
-row's `totals` still reconciles to the period total exactly as before, only the count and
-identity of rows with no `task` changes. Bumped from `4` when `by_task`
-joined the top-level breakdowns (a consumer summing every breakdown's `requests` against
-`totals.requests` now has a fifth breakdown to include). `by_task` groups by the same
-closed, declared intervals the pre-existing `--task` filter already reads โ never a
-second notion of when a task was running โ and is unrelated to `task_attribution`, which
-still exists only alongside a `--task` filter. Bumped from `3` to `4` when `by_person`
-joined the top-level breakdowns and `read` gained `identity_unusable` (a consumer
-summing every breakdown's `requests` against `totals.requests` now has a fourth breakdown to
-include). `identity_unusable` was reshaped from a boolean into a named cause, and the field
-it replaced (`person_mapping_unusable`, over a separate mapping file) was deleted, before
-version 4 ever shipped โ no second bump announces either change, since nothing has read
-this version yet. Bumped from `2` to `3` when `by_model` gained a row with no `model`, for a
-record neither reader that permits one could name (a consumer that read `row.model` as
-always a string on every prior version would misread this one). Bumped from `1` to `2` when
-`by_day` and `by_project` joined `by_step`, `by_model` and `by_tool` as top-level breakdowns.
-
-**Set aside an object whose version you do not recognise rather than guessing its shape.**
-The number is bumped when a consumer that understood the previous shape would misread this
-one. Adding a field you may ignore is not a bump; changing what an existing field means is.
-
-## The shape
-
-```jsonc
-{
- "cost_report_version": 15,
- "period": { "from_day": "2026-07-01", "to_day": "2026-07-31" },
- "measurement_enabled": true, // this project's own switch, right now โ see Versioning
- "task": "2026_08/2026_08_21_cost-reporter", // absent unless --task was given
- "filters": { "project": "acme/widgets" }, // absent unless a generic filter was given
- "empty_selection": { "filter": "project", "value": "acme/ghost", "known": false }, // absent unless a filter, not the period, emptied this selection
- "sessions": 1,
- "totals": { "requests": 2, "input_tokens": 13930, "output_tokens": 4377, "cache_read_tokens": 165632, "cache_creation_tokens": 0 },
- "active_time_s": 2820, // absent when no record carried it
- "by_step": [{ "step": "aidd-dev:02-implement", "attribution": "journal-interval", "totals": {} }],
- "by_model": [{ "model": "gpt-5.6-sol", "totals": {} }], // a row with no "model" names none known
- "by_agent": [{ "agent": "aidd-dev:executor", "attribution": "tool-stated", "totals": {} }, { "attribution": "main-thread", "totals": {} }, { "attribution": "not-stated", "totals": {} }], // "main-thread" is a tool that names agents saying this is none of them; "not-stated" is a tool whose route never names one
- "by_prompt": [{ "prompt": "a-prompt-id", "started_at": "2026-07-01T09:00:00Z", "totals": {} }, { "totals": {} }], // one row per prompt, largest first; the last row, undated, is every record that named none
- "by_tool": [{ "tool": "codex", "coverage": "covered", "reason": "โฆ", "capability": {}, "totals": {}, "session_totals": {} }], // session_totals absent unless the tool has one (Copilot, today)
- "by_project": [{ "project": "acme/widgets", "totals": {} }], // a row with no `project` names none known
- "by_task": [{ "task": "2026_08/2026_08_21_cost-reporter", "attribution": "declared", "totals": {} }, { "reason": "precedes-declaration", "totals": {} }], // a row with no `task` carries `reason` instead, naming which of five facts applies; up to five such rows
- "by_backlog": [{ "backlog": "ai-driven-dev/framework#661", "totals": {} }, { "declaration": "none", "totals": {} }, { "declaration": "unreadable", "totals": {} }, { "reason": "precedes-declaration", "totals": {} }], // a row with no `backlog` carries `declaration` (a known task naming none, or one whose declaration could not be read) or `reason` (a record in no task at all) โ never both, never neither
- "by_flow": [{ "flow": "aidd-orchestrator:01-sdlc", "attribution": "journal-interval", "started_at": "2026-07-01T09:00:00Z", "totals": {} }, { "flow": "aidd-orchestrator:01-sdlc", "attribution": "journal-interval", "started_at": "2026-07-01T11:00:00Z", "totals": {} }, { "flow": "aidd-orchestrator:01-sdlc", "attribution": "tool-stated", "totals": {} }, { "attribution": "unattributed", "totals": {} }], // two runs of the same skill are two rows, told apart by `started_at`; a `tool-stated` row is every run of that skill only the record's own tool named, so it carries no `started_at`; the last row is work that joined neither
- "by_day": [{ "day": "2026-07-01", "totals": {} }], // every day in the period, in order, gaps included
- "by_person": [{ "resolution": "mapped", "person": "a-person-id", "identities": ["a-person-id", "a-machine-id"], "totals": {} }], // mapped rows first, then every unplaced identity, then the one row for records carrying none
- "attribution": [{ "attribution": "tool-stated", "totals": {} }],
- "task_attribution": [{ "attribution": "declared", "totals": {} }], // present only alongside "task"
- "read": { "undated_records": 0, "unreadable_lines": 0 } // identity_unusable absent: the identity was read fine
-}
-```
-
-### Totals
-
-The same object appears as `totals` everywhere โ at the top level and on every row.
-
-| Field | Meaning |
-| --- | --- |
-| `requests` | Billed requests. Always present. |
-| `cost_micro_usd` | Whole micro-dollars. Divide by 1,000,000 for dollars, at the moment of display and not before. |
-| `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | The four counters, disjoint โ adding all four gives total tokens without counting anything twice. |
-
-**An absent counter means never observed, which is not zero.** A tool whose files carry no
-amount has an *unknown* cost, not a free one. Print "unknown", never `$0.00`.
-
-**No amount reaches this object from a local read, on any tool, and no route can add a new
-one going forward.** Claude Code's `cost_usd` only ever existed on its OTLP export, and
-that route was removed โ no code path in this repository can produce a new `"export"`
-record. A stored one written before the removal can still be read (the sink is
-append-only), so `cost_micro_usd` can still appear on this object for a period whose
-history includes that data; it will never appear for work measured after the removal. If
-you are reporting on locally-read sessions, you are reporting tokens; the rates that turn
-them into money live outside this repository.
-
-### Breakdowns
-
-`by_step`, `by_model`, `by_tool`, `by_project`, `by_task`, `by_backlog` and `by_flow` are
-ordered largest first, with a stable tie-break, so the biggest thing is the first thing you
-read. Ordered by `cost_micro_usd` where a row has one, and by all four token counters
-summed where it does not โ never by `input_tokens` and `output_tokens` alone, which every
-tool here dwarfs with cache. `by_task` places every row for what fell in no declared
-interval last regardless of size, in `reason`'s own fixed order (`"no-journal"`,
-`"precedes-journal"`, `"no-declaration"`, `"precedes-declaration"`, `"journal-silent"`) โ the same convention
-`by_person` gives its own no-identifier row, a reader sees tasks before the remainder, and
-the remainder in the
-same order every time. `by_backlog` places its own named rows first, then the row for a
-known task declaring none, then the row for one whose declaration could not be read, then
-every `reason` row in that same fixed order. `by_flow` carries no reason taxonomy the way
-`by_task`'s and `by_backlog`'s own remainders do โ a flow is read from the same sequence
-either way, so there is only one fact to state about falling outside every one of them โ
-but its own single row for what fell in no flow interval is pinned last exactly like
-theirs, never sorted by size with the named rows: that row is ordinarily the largest one
-in a period, and sorting it in would lead the axis with its own remainder while every axis
-beside it leads with its largest named row. `by_day` is the one exception: it is
-chronological, one row per day the period spans โ a series read out of order is not a
-series, and a day nothing ran on is a row of zeros rather than an omitted day.
-
-**Every breakdown sums exactly back to `totals`.** That is asserted, on integers, not
-hoped for.
-
-`by_step` is keyed by the step **and** the strength of its attribution: one skill reached
-once from the tool's own statement and once from a journal interval is two rows, because
-they are two different claims. A row with no `step` carries `attribution: "unattributed"`.
-
-`by_project` carries a row with no `project` for a record stored before this field existed,
-or whose session journal named none โ never folded into a project the reader happens to be
-standing in. A record's project comes from the run journal that covered its session, not
-from wherever the report itself happens to run. An empty string is treated the same as no
-project at all - never its own row.
-
-`by_model` carries a row with no `model` the same way: both the Codex and OpenCode readers
-permit a request with no model, and that record gets its own row rather than vanishing from
-the breakdown while staying in `totals`.
-
-### `by_task` โ grouped by the closed interval a record falls in, or a narrower inferred route of its own
-
-`by_task` groups first by exactly the same declared intervals `--task` already filters on
-(see "Attributing records to a task"): a record's own moment either falls inside one
-session's closed interval, or it does not. A record's session is closed, sequential
-intervals never overlap (see "Attributing records to a task"), so at most one declared
-interval ever matches.
-
-Since `cost_report_version` `12`, a record no declared interval covers can still land under
-a task through a second, narrower route of its own โ never the `--task` filter's own
-"inferred" route wholesale, because that route decides for a whole session at once and, for
-a session that wrote into two task folders, would place the same undeclared record under
-both โ the opposite of what a breakdown promises, which is a partition. `by_task`'s own
-route only fires when the session wrote into exactly **one** task folder and the record's
-own moment falls inside what that session's journal actually witnessed; a record from a
-session that touched two task folders, or one outside the witnessed window, gets no
-task and a `reason` row instead. Bounded this way, one task can hold two rows โ one per
-route โ but a record is never placed under two different tasks.
-
-`attribution` is present on every row that carries a `task`, naming which route placed it
-there โ travelling with the row rather than assumed, so a consumer never has to know which
-route a breakdown reads. A row for what fell in no declared interval and no inferred one
-carries `reason` instead of `task` and `attribution`, naming which distinct fact applies โ
-never one label standing in for all of them, and never more than one row per reason. The
-first names a fact about the read, the rest facts about the work:
-
-`attribution` says which route named the task, and is present only on a row that names one:
-
-| `attribution` | What it means |
-| --- | --- |
-| `"declared"` | A `task_declared` interval in that record's session covers the record's own moment. |
-| `"inferred"` | No declared interval covers it, its session wrote into exactly one task folder, and its own journal witnessed the moment. Weaker on purpose: the journal never said this record was that task's, only that the session touched that task and nothing else. |
-
-| `reason` | What it means |
-| --- | --- |
-| `"no-journal"` | No usable run journal reached this record's session. Either none was read for it at all โ the journals are on another machine, the period was read from outside any checkout, the session's own journal was removed โ or one was read and could not be used, its `session_start` header torn, so nothing here knows which session its lines belong to. Never says the work declared nothing. |
-| `"precedes-journal"` | This record's moment is older than the earliest moment its session's journal witnessed. Nothing declared late here; no journal existed yet. Reading a resumed transcript stores the turns it inherited under the session that read them, dated when each was billed โ so this row is ordinarily the largest one in a period, and says nothing about how the work behaved. |
-| `"no-declaration"` | This record's session never declared a task at all. |
-| `"precedes-declaration"` | A task was declared, this record's moment falls inside what the journal witnessed, and some declared interval starts *after* it โ a record before the session's very first declaration. Declared intervals run contiguously from each declaration to the next, so no gap exists between two of them. |
-| `"journal-silent"` | A task was declared, every declared interval starts at or before this record's moment, and none of them reaches it โ the journal's own declared coverage ran out before this record's moment did. |
-
-Up to five such rows can appear in one period โ one per reason actually present, never
-fewer, and never two different gaps collapsed into one row the way a single, undifferentiated
-"no declared interval" row used to read before `cost_report_version` `6`. A session whose
-declaration could not be read produces the same `"no-declaration"` row a session that never
-declared one does โ there is no signal in the journal that would tell those two apart. Where
-that possibility matters, `read.unreadable_lines` already carries it, for these rows as for
-the whole object.
-
-`by_task` sums to `totals.requests` exactly like every other breakdown.
-
-**Alongside a `--task` filter, a row carrying `reason` can still appear, and is not a
-contradiction of the header naming that task.** `--task` keeps a session's records through
-its own "inferred" route for a record no declared interval covers โ whole-session and
-unbounded by time, so a session that wrote into that task's folder at any point keeps every
-one of its otherwise-undeclared records, whichever task is being filtered for. `by_task`'s
-own inferred route is narrower โ bounded to a session that wrote into exactly **one** task
-folder and to a record inside what that session's journal actually witnessed โ so a record
-`--task` would keep can still land in a `reason` row here. Read the row as it is named: no
-*declared interval, and no bounded inferred route,* covers this record โ not "this session
-never touched a task." Cross-check against `task_attribution`'s own `declared`/`inferred`
-split when the broader, whole-session reading is what is wanted.
-
-### `by_backlog` โ grouped by what each task's own folder declares
-
-`by_backlog` answers a different question than `by_task`: not "which task", but "which
-backlog item" โ the move from "this task cost X" to "issue #661 cost X". It regroups
-`by_task`'s own per-record membership one level up: every record that `by_task` already
-places under a named task is looked up once more, against that task's own
-`aidd_docs/tasks//backlog-link.json`, and re-keyed on the item it declares. A record
-`by_task` places in a `reason` row (no task at all) is untouched โ it carries the identical
-`reason` here, since there is no task to ask a question of in the first place.
-
-A row with no `backlog` carries exactly one of `declaration` or `reason`, never both and
-never neither:
-
-| Field | Value | Means |
-| --- | --- | --- |
-| `declaration` | `"none"` | The record's task is known, but its folder declares no backlog item. A normal state, its own row. |
-| `declaration` | `"unreadable"` | The record's task folder's declaration exists but could not be parsed. Its own row, costing that row's resolution and no figure โ the record is still counted, here and everywhere else. |
-| `reason` | one of `by_task`'s three | The record belongs to no task at all โ see "Attributing records to a task". |
-
-**Two tasks declaring the same item merge into one row โ that is the point of this axis.**
-A story delivered across two task folders, each with its own `backlog-link.json` naming the
-same reference or the same path, reads as one row here, with both tasks' records folded
-into it. Two tasks that each declare nothing do **not** merge into each other's figures by
-virtue of both declaring nothing โ they both land in the single `declaration: "none"` row,
-which is one row precisely because "declares nothing" is one state, not because the two
-tasks were confused for each other.
-
-`by_backlog` sums to `totals.requests` exactly like every other breakdown โ a damaged or
-absent declaration changes how a task's records are grouped, never how many are counted.
-
-### `by_flow` โ grouped by the orchestrated run the journal's own sequence names
-
-`by_flow` answers "what did this orchestrated run cost" โ a level above a step and
-unrelated to a task: `aidd-orchestrator:01-sdlc` running end to end is one flow, whatever
-tasks or backlog items it touched along the way. Nothing new is captured for it. Skill
-detection already writes a `step_start` line for any skill, orchestrating or not (the same
-lines `by_step` reads), so a flow is *read* from that sequence, never declared by a hook:
-one flow opens at an orchestrating skill's own `step_start` and closes at a `step_end`
-naming that same skill, at the next orchestrating `step_start`, or โ for a flow nothing ever
-closes โ at the journal's own last witnessed moment. A `turn_end` is a pause, not a close: a
-flow spanning three prompts is exactly the case a `turn_end` used to cut short.
-
-**Which skills orchestrate is declared, once, never matched from a plugin string.** No
-skill's own frontmatter says it orchestrates, and more than one skill in this framework's
-`aidd-orchestrator` plugin plausibly does. `flow-attribution.ts`'s `ORCHESTRATING_SKILLS`
-names them explicitly, in both spellings the journal can carry for the same skill (an
-argument-stated name and a bare directory name โ see that module's own doc comment for
-why both exist) โ extending it for a project's own orchestrator is the one change adding a
-new one to this axis ever needs.
-
-A row's `flow` names the orchestrating skill, `attribution` says how that flow came to be
-known, and `started_at` names the moment it opened. All three tell two rows apart: **two
-orchestrated runs of the same skill in one session are two rows, never merged into one** โ
-an `attribution: "journal-interval"` row groups by the closed interval a record's own moment
-falls inside, not by the skill's name alone, the same distinction `by_step` already draws
-between a step reached by two different routes. A row with no `flow` at all, carrying
-`attribution: "unattributed"`, is every record that joined neither an interval nor a stated
-flow. There is no `reason` breakdown the way `by_task`'s remainder has one: a flow is read
-from the same sequence whichever way a record misses it, so there is only ever the one fact
-to state.
-
-**A flow a record's own tool named is a flow.** A session resumed after its context was
-compacted invokes nothing again, so no `step_start` hook fires and its journal opens no flow
-โ while the transcript goes on stating the step on every record it produces. A record no
-interval covers, whose own `step_attribution` is `tool-stated` and whose `step` names an
-orchestrating skill, joins a row for that skill carrying `attribution: "tool-stated"`. That
-row has no `started_at`: it is a bucket drawn from however many runs of that skill the tool
-named, and a name is not a run. Only the tool's own statement opens such a row โ a
-`journal-interval` step is an inference from the very intervals already checked, and a
-`prompt-matched` one names a step rather than an orchestration.
-
-**An interval wins over a stated flow, and the reason is granularity, not strength.** Where
-a record falls inside an interval, that interval's row is the one it joins, even though its
-tool stated the same skill. Elsewhere the preference runs the other way โ `withStepBackfill`
-prefers a tool-stated step over a journal-interval one โ and the two are not in conflict,
-because they answer different questions. There the question is *which skill*, and the tool
-naming its own beats an inference from a moment. Here the question is *which run*, and only
-an interval can say.
-
-**A skill a person runs by hand while a flow is open counts inside it.** The journal cannot
-tell a hand-run skill from one the orchestrator itself invoked โ both write the identical
-`step_start` line โ so neither can this breakdown. State the limit rather than guessing past
-it.
-
-`by_flow` sums to `totals.requests` exactly like every other breakdown, and carries no
-filter of its own โ grouping only, the same as `by_person`.
-
-### `by_prompt` โ the axis no host limit can empty
-
-Every other breakdown can read low for a reason that is about the capture, not the work: a
-skill the host never named, a journal that was never written, an identity nobody declared.
-This one groups on `prompt_id`, which the transcript reader resolves for every usage line it
-stores โ subagent lines included, by walking each file's own `parentUuid` chain back to the
-turn that caused the work.
-
-| Row | Means |
-| --- | --- |
-| `prompt` and `started_at` | one prompt, and the earliest moment measured inside it |
-| neither field | every record no prompt could be resolved for |
-
-**Read the second row as a real quantity, not a rounding error.** A sink accumulates across
-reader versions, and a record's fields are fixed the first time its turn is stored: a record
-written before this resolution shipped never gains a `prompt_id`, however often the sink is
-read again. Measured 2026-09-05 on one machine's sink of 30,714 records: 845 carry none โ
-34 written before the CLI stamped a version, 810 by one session's reader before the
-resolution shipped, and exactly one by the current reader, an assistant line whose
-`parentUuid` chain reaches no line naming a prompt. Re-reading recovers little of it: 720 of
-those requests name no line any transcript on disk still holds.
-
-Ordered largest first, like every breakdown but `by_day`; the remainder row is placed last
-rather than ranked, because a bucket drawn from many turns has no size comparable to a single
-turn's. The text rendering prints the ten largest and then says how many it withheld โ one
-row per turn is unbounded where every other axis has a small vocabulary โ while this object
-always carries them all.
-
-### `by_person` โ three outcomes, never a merge
-
-`by_person` resolves each record's `person_id` against the machine's own identity file
-(`~/.config/aidd/identity.json`, or the platform equivalent โ see `aidd telemetry identity`),
-never against a git author, an email or a hostname. That file describes exactly one person:
-its own `person_id`, how it was obtained (`origin`: `"minted"` here or `"adopted"` from
-another machine), and every identifier added onto it with `aidd telemetry identity link`
-(`also_me`). Each row's `resolution` is one of four:
-
-| `resolution` | Means |
-| --- | --- |
-| `mapped` | The identifier is this machine's own person โ its `person_id` or a member of `also_me`. `person` carries the canonical identifier, and `identities` carries every raw identifier behind the row, including that canonical one. |
-| `unresolved` | The identifier is real, but the identity file does not cover it. `identities` carries that one raw identifier; `person` is absent. |
-| `this-machine` | The record carried no identifier of its own, and this machine has declared an identity. `person` and `identities` carry that identity exactly as a `mapped` row does; the two are kept apart because they are different facts โ `mapped` is the record naming a person this identity claims, this is the identity claiming a record that named nobody. |
-| `none` | The record carried no identifier at all **and** no identity is declared โ a different fact from `unresolved`: nobody opted in, rather than somebody did on a machine or tool this identity has not heard of. |
-
-**Two raw identifiers one person declared merge into one `mapped` row; two unplaced
-identifiers never merge into each other.** The identity file describes exactly one person,
-so there is no shape in which two people could claim one identifier in the first place โ
-unlike a lookup table, nothing here can be edited into that state. Rows are ordered by how strong the claim is: `mapped`
-first, then the one `this-machine` row, then every unresolved identity, then the one `none`
-row last; largest first within each group.
-
-`by_person` sums to `totals.requests` exactly like every other breakdown โ a damaged or
-undeclared identity changes how records are labelled, never how many are counted.
-
-### `session_totals` โ a session total, never a sum of requests
-
-`by_tool` rows carry `totals`, summed from `kind: "request"` records, exactly like every
-other breakdown in this object. A `by_tool` row can also carry `session_totals` โ present
-only for a tool whose own file yields one already-complete, per-session figure rather than
-per-request records. Today that is Copilot alone: its `session.shutdown` event reports the
-whole session's four token counters once, at the end, never per call.
-
-**The two are never the same number and are never added together.** `totals.requests`
-counts billed requests; a tool that has none of those (Copilot) reports `requests: 0` there
-regardless of what `session_totals` carries. Read `session_totals` as its own answer to "what
-did this session report", not as a fallback for a zero in `totals`. It carries no
-`cost_usd` โ the tool's own file states no billed amount for it, only a session's tokens.
-
-`session_totals` is absent, never present-and-empty, for every tool that has none โ reading
-it as `{ "requests": 0 }` by default would claim a session total was measured and found
-empty, which is a different fact from the tool never producing this figure at all.
-
-### Attribution
-
-`attribution` always has exactly four rows, in this order:
-
-| `attribution` | Means |
-| --- | --- |
-| `tool-stated` | The tool named the running skill itself, on the line with the counters. Exact. |
-| `prompt-matched` | The record's own prompt opened a step, and both sides name the same one โ an identifier two sources agree on, stronger than an inference from moments and the one reading that stays true when two tasks advance at once. |
-| `journal-interval` | Derived from the interval between two boundaries the framework recorded. An inference. |
-| `unattributed` | Neither source could say. |
-
-A strength that accounts for nothing is present with `requests: 0`. That zero is a
-measurement โ the total is known and none of it came from that source.
-
-**`unattributed` does not mean no step ran.** On at least one measured tool the two are
-indistinguishable, so the stronger reading would be a fact nobody measured. Do not collapse
-it into anything else, and do not call it a residual.
-
-### Task attribution
-
-`task_attribution` exists only alongside `task` โ an unfiltered period carries no
-per-record task identity to break down, so there is nothing here to say for it. Where
-present it always has exactly two rows, in this order:
-
-| `attribution` | Means |
-| --- | --- |
-| `declared` | The record's own moment fell inside an interval a flow explicitly opened, by naming a file under this task's folder in a tool call โ a run journal `task_declared` line. Works on every tool the journal hook reaches, not only the one whose payload names a written path. |
-| `inferred` | The record's session wrote into the task folder at some point, with no declared interval covering this specific record. The pre-existing, whole-session route. |
-
-A source that accounts for nothing is present with `requests: 0`, the same convention
-`attribution` uses. There is no `unattributed` row here: every record inside a `--task`
-report already matched one of the two routes, or it would not be in the report at all.
-
-**A declaration is bounded, never boundless.** It closes at whichever of a later
-declaration or a turn boundary comes next; left open by a session that never closed it
-(a crash, most often), it is capped at the last moment that session's journal actually
-recorded โ never at "still open," which would let one long-running session's later,
-unrelated work read as this task's cost.
-
-### Capability, per tool
-
-This is the field that makes the contract the same across tools. **Branch on it. Never
-infer a tool's limits from whether a number happened to be present** โ a tool that cannot
-supply an amount and a session that cost nothing look identical in the numbers.
-
-```jsonc
-"capability": {
- "local_read": { "token_counters": true, "amount": false, "tool_stated_step": false, "agent_name": false },
- "export": null,
- "journal_attributable": true,
- "task_attributable": false
-}
-```
-
-| Field | Meaning |
-| --- | --- |
-| `local_read` | What that route was **measured** to supply. `null` means the tool declares no such route at all, which is not the same as a declared route supplying nothing. |
-| `export` | **Always `null`, on every tool.** The export route was removed; no tool declares it any more, so there is nothing left to measure a shape for. Kept as a field, rather than dropped from the object, because dropping it would be a `--json` shape change for a capability that used to vary by tool and now simply never does. |
-| `token_counters` | That route yields the four counters. |
-| `amount` | That route yields a figure denominated in currency. Never a credit or a premium request. |
-| `tool_stated_step` | The tool names the running step itself. A journal interval is not this. |
-| `agent_name` | The route names the agent a record belongs to, and so also says when one is the main thread's own. Without it, `by_agent` reads this tool's records as `not-stated`, never as the main thread. |
-| `journal_attributable` | The run journal names this tool's sessions. **False means two things:** no step can come from an interval, *and* a read that sweeps the journal never reaches one of its sessions โ so the tool can be perfectly readable and still report nothing until someone names a session by hand. |
-| `task_attributable` | A session on this tool can be traced to the task it worked on โ declared, inferred, or both. False only where the journal hook never reaches a tool call for this host at all, since a declaration needs a tool call's own arguments to read; true for every declared host today, OpenCode included as of 2026-08-31 (`registry-conformance.unit.test.ts` keeps this tied to the journal hook's own dispatch rather than typed in twice by hand). |
-
-`coverage` is `"covered"` or `"not-covered"`, and `reason` says why when it is the second,
-or what a covered tool's figures cannot be used for.
-
-**Five silences, and only one is a zero.** A tool with `requests: 0` may be: not covered at
-all (`coverage: "not-covered"`, read `reason`), covered but unreachable by the sweep
-(`journal_attributable: false`), covered and reached and idle (a real zero), covered and
-its reader failed (the human output says so; `aidd telemetry read` reports it per tool), or
-covered and reporting only a `session_totals` figure โ `requests: 0` there is correct and
-permanent for that tool, not a silence to explain away.
-
-### What the read could not do
-
-```jsonc
-"read": { "undated_records": 3, "unreadable_lines": 2, "identity_unusable": "unreadable" }
-```
-
-`undated_records` are records carrying no moment at all. They belong to **no** period โ
-the only other moment available is the day the line was stored, which is when AIDD heard
-about the work rather than when it happened. `unreadable_lines` are lines no parser could
-read.
-
-**`undated_records` or `unreadable_lines` present or non-zero means your total is
-partial.** Say so rather than presenting it as whole. `identity_unusable` is a different
-kind of field and does not make a total partial: it names which of two causes kept this
-machine's own identity from resolving records at all, `"unreadable"` for a declared
-identity file that could not be read back, `"absent"` for no identity declared at all
-(the ordinary state of anyone who has never opted in - not a degraded read). Either way,
-exactly as `by_person`'s own section states, a damaged or undeclared identity changes how
-records are labelled, never how many are counted: every record is still counted, in
-`by_person` as `unresolved`, never as a reason to drop a figure. The field itself is
-absent from `read` only when the identity was read back fine - `by_person`'s own rows are
-what shows a resolved identity's effect.
-
-## Filling it
-
-Records reach storage when someone runs:
-
-```bash
-aidd telemetry read # every session the run journal knows
-aidd telemetry read --session
-```
-
-A period that reports nothing usually means its sessions have not been read yet.
-
-## Known limits
-
-[The plugin README](../../plugins/aidd-telemetry/README.md) states what each tool can and
-cannot be measured for, and why. Read it before explaining a missing figure.
diff --git a/aidd_docs/product/metrics-contract.md b/aidd_docs/product/metrics-contract.md
deleted file mode 100644
index fd16627c8..000000000
--- a/aidd_docs/product/metrics-contract.md
+++ /dev/null
@@ -1,925 +0,0 @@
-# Metrics contract
-
-This is the contract for `TelemetrySinkRecord`, the one shape every AI-tool telemetry
-line takes once it reaches storage. It is written for a consumer outside this
-repository โ a pricing service, an aggregator โ that needs to price and attribute a
-session's usage without reading this repository's source.
-
-> **Writing a skill, or anything that reports on AIDD work?** Read
-> [`cost-report-contract.md`](./cost-report-contract.md) instead. It describes the object
-> `aidd telemetry report --json` prints, with the rules below already applied. Reading raw
-> records makes you responsible for the three double-count rules, the split between the two
-> record kinds, and re-read deduplication โ which is worth doing once, in one place, and
-> that place already exists. Everything a correct
-consumer needs is below: the file layout, every field's meaning and presence
-condition, the three ways a naive reader double counts, and what each tool can and
-cannot supply.
-
-> **The export route no longer writes.** The OTLP receiver, the export
-> configuration and the mapper that turned a captured payload into a
-> `provenance: "export"` record were all deleted in "one route, and every
-> sentence about it true" โ no code path in this repository can produce a new
-> `"export"` line today. Everything below that describes the export route is
-> kept, unchanged, because `provenance: "export"` stays a valid value on a
-> stored line: the sink is append-only, a line already written under an
-> earlier version of this tool is never rewritten, and a consumer reading a
-> day file that predates this change still needs every rule below to read
-> that line correctly. Read every present-tense sentence about the export
-> route as a description of a stored line's own shape, not of a route still
-> capable of adding to it.
-
-## Where records live
-
-Records are appended as JSON Lines, one JSON object per line, to a UTC-day file:
-
-```
-~/.config/aidd/telemetry/YYYY-MM-DD.jsonl
-```
-
-or under `$AIDD_TELEMETRY_DIR/YYYY-MM-DD.jsonl` when that environment variable is set โ
-it names this directory outright. `$AIDD_USER_CONFIG_DIR/telemetry/YYYY-MM-DD.jsonl` is
-still honoured, for a machine configured before the two were split, and names the
-directory *above* this one; a consumer has to look in both. The split exists because
-`AIDD_USER_CONFIG_DIR` also relocates `auth.json`, so it could never be the variable a
-team shares. A day file is append-only for its whole life โ lines are never
-rewritten in place, only added. A session's records can span more than one day
-file if the session crosses midnight.
-
-Every record carries `sink_schema_version` (currently `2`). A consumer that does
-not recognize the version on a line should set that line aside rather than guess
-its shape โ a version exists precisely so a future, incompatible shape does not
-get read as this one.
-
-## The two record kinds, and why they are never summed
-
-Every record's `kind` is either `"request"` or `"session"`, and the two measure
-overlapping quantities in incompatible ways.
-
-**`kind: "request"`** is one line per billed request โ one line per call to the
-model that produced a charge. `cost_usd` and the four token counters
-(`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`) on
-a `"request"` line are complete for that request: summing every `"request"` line
-for a session gives that session's true total.
-
-**`kind: "session"`** is a periodic delta of the same quantities, taken from a
-metrics export that flushes on a fixed interval (10 seconds, for Claude Code โ
-`OTEL_METRIC_EXPORT_INTERVAL`) with delta aggregation temporality
-(`aggregationTemporality: 1` in the OTLP payload): each flush reports only what
-changed *since the previous flush*, not a running total. A `"session"` line is
-**not** a per-session cumulative figure, and it is not guaranteed complete โ
-whichever flush windows happened to be exported before the process exited are
-what got captured, and no more. Summing `"session"` lines therefore does not
-reliably reproduce a session's true total, even before double-counting against
-`"request"` lines is considered.
-
-Copilot's is the exception that shows why the kind is drawn where it is: read
-locally rather than exported, it is a **one-shot cumulative total** written once
-at shutdown rather than a delta of a flush window. Both meanings share the rule
-that matters โ a `"session"` line is never added to a `"request"` line, because
-one already contains what the other counts โ so they share the kind. What
-separates them is `provenance`: `"export"` for a flush delta, `"local-read"` for
-a total a tool wrote for itself. A consumer that needs to tell them apart reads
-that field, and no other.
-
-**Measured on one captured session** (Claude Code, `session.id` =
-`22177147-d8cb-4ee1-976f-0ef82bd62491`, captured 2026-08-20). The export route these
-figures came from, and the two OTLP fixtures below, were deleted in "one route, and every
-sentence about it true" โ the figures are kept as historical measurement, on a capture no
-longer in this repository:
-
-| Source | Kind | Lines | `cost_usd` total |
-| ----------------------------------------------- | ----------- | ----- | ----------------- |
-| `otlp-logs-claude-code-subagent.json` fixture (deleted) | `"request"` | 2 | **$0.1605** |
-| `otlp-metrics-claude-code.json` fixture (deleted) | `"session"` | 1 (of 6) | **$0.0151** |
-
-This is not a contradiction: the request lines are every billed request the
-session made; the metric line is one 10-second flush window's own delta. Summing
-the two ($0.1605 + $0.0151 = $0.1756) overstates the session's true cost, and
-using only the metric total ($0.0151) understates it by an order of magnitude,
-because only one flush window was ever captured for this session.
-
-**Rule: take `cost_usd` and the four token counters from `kind: "request"` lines
-only.** Take `active_time_s` from `kind: "session"` lines only โ no `"request"`
-line, on any tool measured so far, carries active time; it exists solely as a
-`"session"`-kind metric.
-
-### One line per datapoint, never merged
-
-A `kind: "session"` line is one metric datapoint, never merged with any other
-datapoint from the same flush. The captured session above produced **six**
-`"session"` lines for one flush window, one per datapoint: `cost_usd`,
-`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, and
-`active_time_s` โ each its own line, each carrying only that one field among the
-six (the other five are absent on that line). A consumer expecting one
-`"session"` line per session, or one line per flush bundling every quantity
-together, reads a fifth (or a sixth) of the truth per line it looks at.
-
-## The other way to double count: a re-read appends unless matched
-
-Local reading (`provenance: "local-read"`) works by re-opening a tool's own
-transcript file, which keeps growing for as long as the session runs. Each read
-sees every turn the file holds so far, not just what is new since the last read.
-To keep a re-read from storing the same turn twice, the writer matches each
-candidate record against what is already stored for that session, on `turn_id`
-alone (never on line content, never on arrival order โ a hash of the line changes
-the moment the tool appends anything else to that same record).
-
-**This match requires a `turn_id`.** A candidate with no `turn_id` cannot be
-matched against anything, and is appended again on every read that sees it โ by
-design, not by omission: inventing an unstable key would be worse than leaving it
-unmatched.
-
-**A matched candidate is not always dropped โ a still-open turn can correct
-itself.** Some tools (Codex's rollout is the one measured so far) emit a
-`kind: "request"` record for a turn before the tool itself has said the turn is
-finished: a Codex turn is closed by the *next* `turn_context` line, and the last
-turn in a file whose session is still running has none, so a rollout read while
-its session runs is stored with whatever counters had arrived by then โ partial,
-and, without this rule, permanently so, since the next read's completed figures
-would match the same `turn_id` and be silently dropped. Instead: a matched
-candidate lands as a second line โ the sink is append-only, so this is never an
-edit of the stored one โ whenever it is a `kind: "request"` local-read record
-that **strictly improves** on the largest already stored for that `turn_id`
-(every counter at least as large, at least one larger). Nothing else gates it:
-a run journal's `turn_end` line, where one exists, says only that no further
-growth is coming โ it is never asked before accepting a correction, because a
-candidate that strictly improves is itself the only proof needed that an
-earlier reading was not final, whatever a journal's own clock says about it.
-Idempotence โ a re-read that brings nothing larger stores nothing new โ falls
-out of "strictly improves" alone, the same property an unmatched-then-matched
-`turn_id` already had; nothing here depends on a session ever being confirmed
-finished. A `kind: "session"` record sharing a `turn_id` (Copilot's shutdown
-total, keyed on the shutdown event's own id) is never treated as a correction
-opportunity: it is a one-shot cumulative figure with no provisional reading to
-correct, and is dropped on a re-read exactly as before.
-
-Because more than one record can now legitimately share a `turn_id` on the
-sink, a consumer reading raw records must also collapse them before summing โ
-see "Consuming a session correctly" below. The largest wins, never a sum of two:
-a later record that reads *smaller* than one already stored is never written at
-all, so a stored `turn_id` group never needs to guard against a shrink, only
-against counting more than one of its members.
-
-**Worked example**, mirroring the tested behavior of the local-read use case: one
-turn's transcript line carries 10 input tokens and 20 output tokens, under
-`turn_id: "req_1"`. A first read appends it โ 30 tokens stored. The session
-continues and the same transcript file is read again (a re-read, same turn still
-present in the file, no larger reading of it yet). Because `req_1` is already
-stored and the new reading offers nothing more, the second read matches it and
-stores nothing new. **Stored total after the second read: 30 tokens, not 60.**
-Had the same candidate carried no `turn_id`, the second read would have appended
-it again, and the stored total would have become 60 tokens for one real turn.
-Had the second read's own transcript line instead carried 15 input tokens and 25
-output tokens for the same still-open turn, it would land as a second,
-correcting line, and the group's true total would be 40 tokens โ the larger
-reading, not 30, not 70.
-
-A consumer aggregating raw appends from the sink file without replicating this
-`turn_id` match โ for example, re-implementing a local reader against a tool's
-own files rather than consuming this sink โ will double, triple, or *N*-times
-count any record whose route has no stable per-record identifier, once per read
-of an active session; and, for a route whose turns can be re-read while still
-open, will sum a turn's own successive readings of itself rather than keeping
-only the largest.
-
-## A third way to double count: one billed call, seen by both routes
-
-A tool whose export and local read were both declared and measured โ only
-Claude Code, ever โ could have both routes live for the same session at
-once: the OTLP export streamed `api_request` as each call completed, and a
-local read of the same session's transcript, run at any point, sees the same
-calls in the tool's own file. Each route names the call in a namespace the other never
-reads โ export's `turn_id` is `prompt.id`, one user turn, which a main-agent
-request and the subagent it spawned can share (see `turn_id` below); local's
-`turn_id` is `requestId`, one billed call โ so matching on `turn_id` alone, as
-the re-read rule above does, never catches this: the two routes' records for
-the same real call never share that key, and a consumer that only guards
-against the re-read case sums both. **Measured on a captured export fixture no longer in
-this repository** (`otlp-logs-claude-code-subagent.json`, deleted with the export route it
-described, a main-agent request and the subagent request it spawned): naively unioning
-those two export records with what a local read of the same two calls would
-produce doubles every figure โ four `"request"` lines instead of two, every
-token counter twice its true value.
-
-**The fix is not a write-time match.** The sink is append-only (see "Where
-records live"): a record already stored can never be corrected in place, only
-reconciled by whatever reads it back. `billed_request_id` is the field that
-makes the reconciliation possible โ the one identifier measured so far that
-both routes compute the same value for, for the same real call (see
-`billed_request_id` below). A consumer building a session's true totals groups
-`"request"` records sharing `tool`, `vendor_id` and `billed_request_id` and
-treats them as one billed call: keep the group's `cost_usd` and four token
-counters from whichever record carries `cost_usd` at all (on every tool
-measured so far, that record's counters are also the complete ones for the
-call), and take `step_attribution`/`step`/`step_plugin` from whichever record
-resolved one, so a call seen by both routes still shows the export's money and
-the local read's tool-stated step, never one thrown away for the other. A
-record with no `billed_request_id` joins no group and is counted exactly as it
-arrived โ the same rule an unmatched `turn_id` follows for the re-read case.
-`cost-report-contract.md` calls this the third of the double-count rules
-`aidd telemetry report --json` has already applied.
-
-**The same collapse also absorbed a retried OTLP delivery, while the receiver
-still ran.** `/v1/logs` and `/v1/metrics` were received unconditionally โ
-nothing at write time recognized a redelivered payload, because OTLP delivery
-is itself at-least-once and a receiver refusing a delivery it cannot prove is
-a duplicate would risk refusing a real one. A redelivered `api_request` named
-the same `billed_request_id` as the first delivery, so the group it joined on
-read had two (or more) identical records instead of one, and the same "keep
-one, never sum" rule that reconciles two routes reconciled two deliveries of
-one route just as well. A day file written while the receiver still ran can
-still hold this shape; a consumer reading one applies the same collapse.
-
-**This protection required `billed_request_id`, and most exports never carried
-one.** Measured while the route still existed, only Claude Code's export named
-it (its `request_id` log attribute). Codex's and Copilot's exports were once
-declared โ `identityAttribute` was real, measured from a captured session โ
-but neither was ever measured carrying a `request_id`, or any counter at all
-through this route; Cursor's and OpenCode's exports were never even declared
-(`kind: "unmeasured"`), so the receiver never resolved an identity for either
-and stored nothing from them regardless. A route with no `billed_request_id`
-has no group to collapse into: a retried delivery for it is indistinguishable
-from two real calls, and doubles every counter it carries. That was always a
-latent gap, not an observed one, on any stored line โ the one route ever seen
-carrying real counters (Claude Code's) was also the one route that was
-protected. A consumer reading a stored `"export"` line today reads it against
-exactly the coverage measured while the route ran; no export will ever carry
-`billed_request_id` beyond what is already on disk.
-
-## Identity and joins
-
-- **`tool`** names which AI tool produced a record, as a fact stated on the
- record itself. A consumer never infers the tool from the name of another
- field (`vendor_field`, `vendor_id`) โ that attribute name differs by tool
- *and by route*: the same Claude Code session identifier is named `sessionId`
- when read locally and `session.id` when exported. Reversing the attribute name
- back into a tool identity works only until a tool reuses another's attribute
- name.
-- **`vendor_id`** is that tool's own session identifier, as a string, in
- whatever form the tool itself uses it โ a UUID for Claude Code and Codex, an
- OpenCode `ses_โฆ` id, and so on. **`vendor_field`** names which attribute
- carried it (`sessionId`, `session.id`, `session_meta.id`, `sessionID`,
- `conversation.id`, `gen_ai.conversation.id`, depending on tool and route). Two
- records with the same `tool` and the same `vendor_id` describe the same real
- session, regardless of which route produced either one, since the identifier
- value itself is the tool's own and does not change between its local file and
- its export.
-- **`project_id`** is the repository a session ran in, when it is known.
- On the export route it is set directly from the `aidd.project_id` resource
- attribute, with no join and no `project_field`. On the local-read route it
- is joined from the run journal's own `session_start` line, which already
- resolves `project_id` and `project_remote` for the repository the hook
- fired in โ the same value never re-derived from wherever the reader
- happens to be standing. **`project_field`** names which of the journal's
- two fields the value came from, present only on a record joined this way:
- `"project_remote"` when the journal named a git remote (the same value for
- every checkout of one repository), `"project_id"` otherwise (a directory
- name, which can collide across machines). A record with neither field
- belongs to no known project โ never guessed at from the current
- repository, and never dropped.
-- **`turn_id`** is the tool's own identifier for one turn or request, when the
- tool's file or export can name one. It is the key local-read re-reads are
- matched on (above), but **it is not guaranteed unique to one billed request**:
- measured on the captured session above, a main-agent request and the subagent
- request it spawned share one `prompt.id` โ two `"request"` lines, $0.1086 and
- $0.0519, both under the same `turn_id`. Do not use `turn_id` as a primary key
- for billed requests; use it only for the re-read match it exists for.
- **`turn_field`** names which attribute carried it.
-- **`person_id`** is not a tool's own, uncontrolled user attribute. This sink
- once stored that as `user_id`, mapped straight from whatever `user.id` an
- export happened to carry, regardless of whether the person using the tool
- agreed to be named; that field is gone, and an export-provenance record now
- carries no identity of any kind. `person_id` is the opposite in every way
- that mattered: an identifier a person generated for themselves, on their own
- machine, opted into per person rather than defaulted on for a whole export.
- It is present only on a `provenance: "local-read"` record โ the one route
- guaranteed to run as that person, on that machine โ and never on an
- `"export"` record: no receiver exists any more to run as that person at
- all, and even while one did, it was never guaranteed to run on the
- identified person's own machine. `person_id` is never derived from `user_id`,
- a git author, an email, or a hostname, in either direction. See `person_id`
- and `person_display_name` below for what each carries and when.
-
-## Joining a commit to the session that made it
-
-Not a field of a record โ it lives in git, and is the one link between what was
-measured and what was produced.
-
-`aidd telemetry on` installs a `prepare-commit-msg` hook. A commit written while
-an AI session was running carries a trailer:
-
-```text
-AIDD-Session-Id: 33333333-3333-4333-8333-333333333333
-```
-
-The value is the running session's own identifier, resolved from the environment.
-It is not a second identifier minted for this purpose, so a commit joins to its
-records on an equality with no resolution step in between. That equality is
-measured on both hosts that write a trailer โ see "How far the join is measured"
-below, which also names the one case still open.
-
-Rules a consumer can rely on:
-
-- **A commit no AI session made carries no trailer.** The hook reads the running
- session from the environment and writes nothing when it finds none. An absent
- trailer means "not attributable", never "attributable to nobody".
-- **A merge or a squash carries none either**, whatever was running. Those commits
- bring in work that other commits already account for.
-- **At most one per commit**, amend and re-run included.
-- **What a commit already carries is never rewritten.** `aidd telemetry off`
- removes the hook, so commits made after it carry nothing while the history keeps
- what it has. Note it is that command which removes it, not the switch's value:
- a project whose `.aidd/config.json` is edited by hand to `enabled: false` still
- has the hook installed, and still trailers.
-- **The trailer is not proof the session's records exist.** Turning the switch on
- installs the hook, and a session whose records were never read, or were removed
- with `aidd telemetry forget`, still leaves its trailer behind. A join that finds
- no records on the other side is an ordinary outcome.
-
-### How far the join is measured, per host
-
-**Claude Code โ measured.** `CLAUDE_CODE_SESSION_ID` is the transcript filename,
-and a session is resolved locally by `.jsonl`, so the trailer's value
-and the record's `vendor_id` are the same string.
-
-**Codex โ measured, on two real sessions.** A fresh session and a resumed one
-were run on 2026-09-02. Both reported the same `CODEX_THREAD_ID`, and the resume
-wrote **no second rollout** โ it appended to the first. So the variable tracks the
-rollout, and the rollout's uuid is exactly the `vendor_id` records carry: the
-trailer's value equals theirs. An earlier version of this section warned that a
-thread spans several rollouts; the resume disproved it.
-
-One case is still open, and it is both narrow and bounded: 89 of 418 rollouts on
-that machine are `thread_source: "subagent"`, where the rollout's own id and its
-parent session's differ. Which of the two a subagent's `CODEX_THREAD_ID` carries
-has not been captured.
-
-What that can cost is worth stating, because it is less than it sounds: those two
-identifiers are the subagent and the thread that delegated to it, so the trailer
-names one or the other and both are 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, the outcome is the ordinary
-"a join that finds no records on the other side" named above.
-
-Every ordinary session, fresh or resumed, joins exactly.
-
-**Cursor, Copilot, OpenCode โ no trailer.** Nothing was measured that names their
-running session in the environment, so the hook finds nothing and writes nothing.
-That is the same rule as everywhere else here: an unknown is never a guess.
-
-The link the trailer closes is `execution โ commit`. From there, `commit โ pull
-request โ ticket` is the forge's own, and belongs to whoever reads it โ this
-framework neither writes nor resolves those.
-
-## Step attribution
-
-Every record states **how**, not just whether, its step is known, via
-`step_attribution`: `"tool-stated"` (the tool itself reported the running
-step, exact for that record), `"journal-interval"` (derived: the record's own
-moment fell inside a step's start/end interval recorded by AIDD's run journal โ
-an inference, not a measurement), or `"unattributed"` (no step could be
-determined by either route). `step_attribution` is always present; it is never
-omitted, because an absent field here would read as "no step ran," which is
-exactly the assertion nothing on a transcript or a journal can support.
-
-`step` (the skill or step name) is present exactly when `step_attribution` names
-a source that found one โ absent, never a placeholder, when `step_attribution`
-is `"unattributed"`. `step_plugin` (the plugin the step came bundled with) is
-present only when `step_attribution` is `"tool-stated"` and the tool reported a
-plugin alongside the step name; a journal interval never carries a plugin at
-all, so `step_plugin` is absent whenever `step_attribution` is
-`"journal-interval"`, even though `step` itself is present there.
-
-**A journal interval ends where the journal says, and only a skill can say it.** The
-interval runs from the `step_start` a skill's invocation wrote to the first of: a `step_end`
-line naming that same skill, another `step_start`, or a `turn_end`. Only the first of those
-is the end itself; the other two stand in for it. That matters because a `turn_end` is a
-**pause** โ a skill working across three prompts is credited with its first turn and nothing
-after โ and nothing any host emits says when a skill's work finished. Measured: a `Skill`
-tool call's own `tool_result` returns about a tenth of a second after the call, which is the
-dispatch, not the completion. So a skill declares its own end through a tool call it makes,
-carrying `aidd:step-end ` in that call's arguments, and the hook writes the line. An
-end naming a skill the session never started closes nothing, and an end never closes a step
-other than the one it names โ a skill invoking another must not end it.
-
-A step whose interval was closed by a stated `step_end` still reads `"journal-interval"`, not
-a stronger value: the record was still placed by its moment falling inside a span, which is
-the inference that value names. What the end changes is the span, never how the record met
-it.
-
-**`step_attribution: "unattributed"` does not mean "this request ran outside any
-step."** Claude Code's own attribution field is omitted from its transcript both
-when no skill was running and when the running Claude Code version predates the
-field (it arrived around version 2.1.220). Measured across 40 real transcripts
-and twelve versions, there is not one `null` value that distinguishes the two
-cases โ the field is omitted identically either way. A consumer that reads
-`"unattributed"` as "confirmed to be outside any step" is asserting a fact the
-data cannot support. Read it only as: no step could be determined for this
-record, for whatever reason.
-
-## Field reference
-
-Every field below states its type, when it is present, what it means, and โ
-because an absent counter and a zero counter are different facts โ what its
-absence means.
-
-### Always present
-
-#### `sink_schema_version`
-- **Type**: number.
-- **Present**: always.
-- **Meaning**: the wire format version this line was written under. Currently `2`.
-- **If absent**: never absent on a well-formed line; a line missing it, or
- carrying a version a consumer does not recognize, should be set aside rather
- than parsed as if its shape were known.
-
-Adding `person_id` and `person_display_name` was not a version bump: a
-consumer built against version 2 that never wrote or read either field never
-sees them, and neither changes what any field it already understood means โ
-the same rule `cost_report_version` follows in `cost-report-contract.md`.
-
-Removing a field is neither of the two cases above โ it neither adds
-something ignorable nor changes what a kept field means. Whether it needs a
-bump turns on whether a version has shipped: once a consumer could be reading
-a real line, taking the field away without warning breaks it silently. Before
-that, the risk does not exist โ nobody can depend on a field no released
-build ever produced. Removing `user_id` from the allowlist is not a version
-bump for exactly that reason: `sink_schema_version` 2 has never been
-released, so no consumer anywhere has read a real line carrying it. A day
-file a pre-release build already wrote may still carry `user_id` on an old
-line โ the sink is append-only (see "Where records live") โ and a reader
-ignores it exactly as it would any other field it does not recognize.
-
-#### `kind`
-- **Type**: `"request"` or `"session"`.
-- **Present**: always.
-- **Meaning**: which of the two measurement kinds this line is โ see "The two
- record kinds" above.
-- **If absent**: never absent.
-
-#### `provenance`
-- **Type**: `"export"` or `"local-read"`.
-- **Present**: always.
-- **Meaning**: which route produced this line โ a tool's OTLP export received
- over `/v1/logs` or `/v1/metrics`, or a tool's own file read directly from disk.
- Never defaulted, so a third route arriving later cannot be mistaken for one of
- these two.
-- **If absent**: never absent.
-
-#### `tool`
-- **Type**: one of `"claude"`, `"cursor"`, `"copilot"`, `"opencode"`, `"codex"`.
-- **Present**: always.
-- **Meaning**: the AI tool that produced this record, stated directly โ see
- "Identity and joins" for why this is never inferred from another field.
-- **If absent**: never absent.
-
-#### `vendor_id`
-- **Type**: string.
-- **Present**: always.
-- **Meaning**: the tool's own session identifier โ see "Identity and joins."
-- **If absent**: never absent.
-
-#### `vendor_field`
-- **Type**: string.
-- **Present**: always.
-- **Meaning**: which attribute on the source payload carried `vendor_id` โ the
- route as much as the tool (the same tool's own identifier can be named
- differently on its local file versus its export).
-- **If absent**: never absent.
-
-#### `step_attribution`
-- **Type**: `"tool-stated"`, `"journal-interval"`, or `"unattributed"`.
-- **Present**: always.
-- **Meaning**: how the step (if any) was determined โ see "Step attribution."
-- **If absent**: never absent, deliberately โ see "Step attribution" for why an
- absent field here would be misread.
-
-### Identity and joins (conditional)
-
-#### `turn_id`
-- **Type**: string.
-- **Present**: conditional โ when the producing route can name a stable
- identifier for this specific turn or request. Present on most `"request"`
- lines measured so far (Claude Code, Codex, OpenCode). Never present on any
- export-route metric datapoint (no `"session"`-kind OTLP datapoint measured so
- far carries a turn identifier), but present on Copilot's local-read
- `"session"` line โ its one-shot shutdown total is keyed on the shutdown
- event's own id, so a re-read can match it the same way a `"request"` line's
- is matched.
-- **Meaning**: the tool's own turn/request identifier, and the key a local
- re-read is matched on. **Not guaranteed unique per billed request** โ a
- main-agent request and the subagent request it spawns can share one `turn_id`
- on the export route. A `kind: "request"`, `provenance: "local-read"` turn is
- the one shape that can also be *corrected*, not just matched โ see "A re-read
- appends unless matched" above; a `"session"`-kind turn (Copilot's) and an
- export-route turn are never corrected this way, only matched-and-dropped or
- left unmatched.
-- **If absent**: this record's route has no stable per-record identifier to
- offer. It cannot be matched by a re-read, and will be appended again, once per
- read, for as long as the session's underlying file keeps being re-read โ see
- "A re-read appends unless matched."
-
-#### `turn_field`
-- **Type**: string.
-- **Present**: conditional โ present exactly when `turn_id` is present.
-- **Meaning**: which attribute on the source payload carried `turn_id`
- (`requestId`, `prompt.id`, `turn_id`, `id`, depending on tool and route).
-- **If absent**: `turn_id` is also absent on this record.
-
-#### `billed_request_id`
-- **Type**: string.
-- **Present**: conditional โ measured so far only for Claude Code, on both routes:
- its export names it via the `request_id` attribute on the `api_request` log
- record, and its local transcript names it via `requestId` โ the same
- attribute the local route already uses for `turn_id`. No other tool or route
- has ever been measured naming a billed call this way.
-- **Meaning**: the tool's own identifier for one billed call, and, unlike
- `turn_id`, **guaranteed unique per billed request where it is present at
- all** โ a main-agent request and the subagent request it spawns each carry
- their own. It exists so a consumer can collapse two records describing one
- real call โ made when both an export and a local read are live for the same
- tool at once โ into one, instead of summing both. See "One billed call,
- both routes" below. Never used for the local-read re-read match `turn_id`
- exists for, and never a primary key for anything beyond that collapse.
-- **If absent**: this record's route has no stable per-call identifier to
- offer beyond `turn_id`, or the record's tool has never been measured
- carrying one. A record with no `billed_request_id` is never collapsed with
- any other โ it is kept exactly as it arrived, the same rule an unmatched
- `turn_id` already follows for a local re-read.
-
-#### `step`
-- **Type**: string.
-- **Present**: conditional โ present exactly when `step_attribution` is
- `"tool-stated"` or `"journal-interval"`.
-- **Meaning**: the skill or step name that was running.
-- **If absent**: `step_attribution` is `"unattributed"` โ no step name is known,
- which is a different fact from "no step was running." Never a placeholder
- string.
-
-#### `step_plugin`
-- **Type**: string.
-- **Present**: conditional โ present only when `step_attribution` is
- `"tool-stated"` *and* the tool reported a plugin name alongside the step.
-- **Meaning**: the plugin the stated step came bundled with.
-- **If absent**: either no step is known, the step came from a journal interval
- (which never carries a plugin), or the tool named a step with no plugin.
-
-#### `project_id`
-- **Type**: string.
-- **Present**: conditional. On the export route: present when the emitting
- environment set a project identity (the `aidd.project_id` resource
- attribute). On the local-read route: present when the run journal's
- `session_start` line named a project for the session โ see "Identity and
- joins."
-- **Meaning**: the repository this session ran in.
-- **If absent**: no project identity is known for this record โ read as
- belonging to no known project, never attributed to a guess.
-
-#### `project_field`
-- **Type**: `"project_id"` or `"project_remote"`.
-- **Present**: conditional โ present only on a local-read record whose
- `project_id` was joined from the run journal; absent on an export-route
- record, whose `project_id` is set directly with no journal join to name a
- source for.
-- **Meaning**: which of the journal's own two fields `project_id` came
- from โ see "Identity and joins."
-- **If absent**: either the record carries no `project_id` at all, or it
- does and came from the export route.
-
-#### `person_id`
-- **Type**: string.
-- **Present**: conditional โ present only on a `provenance: "local-read"`
- record, and only when the machine that read it holds a file recording that a
- person opted in. Never present on a `provenance: "export"` record.
-- **Meaning**: a stable identifier a person generated for themselves and chose
- to attach, on their own machine โ never derived from the export route's
- now-removed `user_id`, a git author, an email, or a hostname. Withdrawing
- removes the file this comes from; records already written keep whatever
- they were stamped with, since a day file is never rewritten in place (see
- "Where records live").
-- **If absent**: nobody opted in on the machine that produced this record โ
- the default for a fresh installation โ or the record came from the export
- route, which never carries this field regardless of any opt-in.
-
-#### `person_display_name`
-- **Type**: string.
-- **Present**: conditional โ present only alongside `person_id`, and only once
- the person separately asked for a display name to be shown. Setting one
- never happens as part of opting in.
-- **Meaning**: a name the person chose to show beside their figures. Never
- derived from `person_id`, and never used to derive it.
-- **If absent**: either nobody opted in at all, or they opted in without ever
- setting a display name โ the ordinary state, not an incomplete one.
-
-#### `cli_version`
-- **Type**: string, semver.
-- **Present**: conditional โ present only on a `provenance: "local-read"`
- record, stamped by `read-local-cost-use-case.ts` at the moment it stores the
- record, read through the same port `current-version-adapter.ts` already
- resolves the CLI's own version through. Never on a `provenance: "export"`
- record: that route's records were never produced by this CLI at all โ a
- different process, a tool's own SDK, gone even earlier (see `provenance`
- above) โ so there is no version of *this tool* to name on one.
-- **Meaning**: which build of the AIDD CLI stored this record โ never the
- framework's own version, which stored nothing, and never the plugin's,
- which stamps a different field on a different artefact (see
- `aidd_docs/runs/README.md`'s `plugin_version` on the `session_start` line).
- Comparing this field across two records is a real answer to "did this
- figure change when I upgraded" in a way neither of the other two versions
- can be, since it alone names what actually computed and wrote the line.
-- **If absent**: the record was stored by a build of this CLI before this
- field existed. Reads as an unknown version, never as the current one or any
- other guess โ inventing a default here would make an upgrade comparison
- silently wrong in the one place a version exists to serve it.
-
-### Cost and token counters (conditional)
-
-#### `cost_usd`
-- **Type**: number, US dollars.
-- **Present**: conditional. On `"request"` lines: present on every
- export-route record (a log record without `cost_usd` is not a billed request
- and is never turned into a record at all) and **never** present on a
- local-read record for any tool measured so far โ no local reader has
- captured a billed amount from a tool's own file. On `"session"` lines:
- present on exactly the one (of six) datapoint lines per flush that carries
- the cost measure.
-- **Meaning**: the billed amount for this request, or this flush window's delta.
-- **If absent**: on a local-read `"request"` line, this route cannot see a
- billed amount for this tool at all โ see the coverage table. On a
- `"session"` line, this is one of the other five datapoints in the flush, not
- the cost one.
-
-#### `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`
-- **Type**: number.
-- **Present**: conditional, and independently per field. On `"request"` lines:
- Claude Code (both routes) reads all four together or none โ a partial
- `usage` object yields no record at all, rather than a record with a missing
- counter silently read as zero. Codex reads each independently: a counter a
- turn never reported (Codex sometimes omits `cache_write_input_tokens`
- entirely, rather than sending zero) stays unset on that record rather than
- being summed in as a fabricated zero. On `"session"` lines: exactly one of
- these four fields is present per line โ see "One line per datapoint, never
- merged" โ the other three, plus `cost_usd` and `active_time_s`, are absent on
- that same line.
-- **Meaning**: token counts for the request or the flush delta, normalized to
- mean the same thing across tools (OpenAI's Responses API convention makes
- Codex's raw `input_tokens` *inclusive* of its cached figure; this field
- subtracts the cache figure out, matching Claude Code's already-exclusive
- convention).
-- **If absent**: this specific counter has no known value for this record โ a
- fact distinct from a stored `0`, which means the tool reported the counter
- as exactly zero.
-
-#### `model`
-- **Type**: string.
-- **Present**: conditional โ present when the producing route names a model
- for this record (Claude Code, on both routes and both kinds; Codex, on
- `"request"` lines via its own `turn_context`).
-- **Meaning**: the model identifier the tool itself used, unmodified.
-- **If absent**: this route did not carry a model name for this record.
-
-#### `effort`
-- **Type**: string.
-- **Present**: conditional โ present when the route carries it (Claude Code,
- both routes; Codex, local read).
-- **Meaning**: the tool's own effort/reasoning-level setting for the request.
-- **If absent**: not carried by this tool's route.
-
-#### `speed`
-- **Type**: string.
-- **Present**: conditional โ measured so far only on Claude Code's export
- route.
-- **Meaning**: the tool's own speed tier for the request.
-- **If absent**: not carried by this tool's route.
-
-#### `query_source`
-- **Type**: string.
-- **Present**: conditional โ measured so far only on Claude Code's export
- route (values seen: `"main"`, `"sdk"`, `"agent:builtin:general-purpose"`).
-- **Meaning**: what originated the request within the tool (its own
- main loop, its SDK, a named built-in agent).
-- **If absent**: not carried by this tool's route.
-
-#### `agent_name`
-- **Type**: string.
-- **Present**: conditional โ present when the record is a subagent's own
- request. On Claude Code: set from the export's `agent.name` attribute, and
- from the local transcript's `attributionAgent` field when the transcript
- line is itself marked as a subagent line (`isSidechain: true`).
-- **Meaning**: which named subagent made this request.
-- **If absent**: for Claude Code, this was the main agent's own request, not a
- subagent's. For every other tool measured so far, this field is never set at
- all โ its route does not name subagents as a concept, so its absence there
- says nothing about whether one ran.
-
-#### `prompt_id`
-- **Type**: string.
-- **Present**: conditional โ Claude Code only, and only where its transcript
- lets the prompt be resolved.
-- **Meaning**: the prompt this billed call belongs to. A billed call and the
- prompt that caused it never share a transcript line: measured on a real
- 810-record session, zero lines carry both `requestId` and `promptId`, only
- `type: "user"` lines carry the second, and all 209 lines bearing counters
- reach one by following `parentUuid` โ three hops in the median. The reader
- walks that chain and stores what it finds.
-- **Why it exists**: the run journal writes the same identifier on `step_start`
- (Claude Code hands its hooks `prompt_id`, stored there under the name
- `turn_id`). Matching the two joins a step to a record exactly, rather than
- inferring it from which interval each moment happens to fall in โ the only
- route that stays true when two tasks advance at once, since two prompts remain
- two prompts however their moments overlap. Two tasks inside **one** prompt stay
- indivisible: a billed amount cannot be split without inventing a ratio.
-- **If absent**: the chain reached no line naming a prompt โ a transcript
- truncated mid-write, or a host whose files carry no such identifier, which is
- every tool but Claude Code today. Never read as "no prompt ran".
-
-#### `prompt_skill`
-- **Type**: string.
-- **Present**: conditional โ Claude Code only, and only where a `Skill` call was
- made inside the record's own prompt.
-- **Meaning**: the skill that call invoked. The same fact the run journal writes
- as `step_start`'s `turn_id`, read from the transcript instead of from a hook.
- The first call wins where a prompt made several: a prompt that invokes two
- skills invoked the second from inside the first, and it is named for the work
- it began.
-- **Why it exists**: the report never re-reads a transcript โ it reads this sink
- and the journals beside it โ so an observation only a transcript holds has to
- be written down when it is read or it is gone. It names a step for a session
- the journal never saw, which is every session that ran before the hook was
- installed. Measured on one machine: 28 such prompts across 22 days, 318 records
- named by that route and by nothing else.
-- **Scoped to one transcript**: Claude Code writes a session's subagents to their
- own files, and a prompt is often spread across several โ measured on one
- machine, 1,038 of 5,564 prompts appear in more than one file. A record names the
- first skill invoked inside its prompt *in the file it sits in*. A subagent that
- invoked its own skill did that work under that skill; merging the files first
- would have to pick one answer for both, and neither is true of both.
-- **Not a duplicate of `step`**: that one reads `attributionSkill`, which Claude
- Code writes per message โ exact where it appears and sparse where it does not.
- Measured inside the window one skill demonstrably ran: 142 lines carry counters
- and 20 carry that field. Its absence is therefore not the tool saying no skill
- ran, and naming the skill a prompt invoked contradicts nothing it states.
-- **Never a judgement**: which step a record belongs to is derived fresh on every
- report, from this and from the journal together. The journal wins where both
- name a skill for the same prompt โ it was written by a hook the host fired,
- where this is read back afterwards.
-- **If absent**: the record's prompt invoked no skill, the chain reached no
- prompt at all, or the tool is not Claude Code. Never read as "no skill ran".
-
-#### `duration_ms`
-- **Type**: number.
-- **Present**: conditional โ measured so far only on Claude Code's export
- route.
-- **Meaning**: the request's own wall-clock duration, in milliseconds.
-- **If absent**: not carried by this tool's route.
-
-#### `active_time_s`
-- **Type**: number.
-- **Present**: conditional โ the field target of exactly one `"session"`-kind
- metric measure, measured so far only for Claude Code
- (`claude_code.active_time.total`). Never present on any `"request"` line, on
- any tool.
-- **Meaning**: seconds of active engagement Claude Code measured during this
- flush window โ not wall-clock time, and not a per-request figure.
-- **If absent**: no `"request"` line carries this at all โ it exists solely as
- a `"session"`-kind measure; on a `"session"` line, this is one of the other
- five datapoints in the flush, not the active-time one.
-
-#### `event_timestamp`
-- **Type**: string, ISO 8601.
-- **Present**: on every route measured so far, from its own source:
- - **Export**, both kinds: the OTLP record's own `timeUnixNano` (nanoseconds
- since the epoch, converted here to milliseconds). The `event.timestamp`
- attribute is read in preference when a payload carries one, but no captured
- payload ever has.
- - **Claude Code, local**: the transcript line's `timestamp` field.
- - **Codex, local**: the turn's own *start*, from the `turn_context` event โ
- not a moment inside the turn. A record spans a whole turn, so a moment
- inside it would claim a precision the record does not have.
- - **OpenCode, local**: the message's `time.created`, in epoch milliseconds.
- Not `time.completed`, which is absent on some counted messages โ a field
- that sometimes means "started" and sometimes "finished" is worse than one
- that always means the same thing.
-- **Meaning**: when the work this record measures happened. Two consumers rely
- on it and they are separate: attributing a record against a run-journal step
- interval when `step` is not already tool-stated, and placing the record in a
- reporting period.
-- **If absent**: two things become impossible, and neither may be substituted
- for. The record can no longer be attributed via a journal interval (only via
- a tool-stated `step`, if one exists), so it falls back to
- `step_attribution: "unattributed"`. And it belongs to **no period**: the only
- other moment available is the day file it was appended to, and that is when
- the record was received, not when the work ran โ a session read locally days
- after it happened lands in the day file for the day it was *read*. A consumer
- reports such records as undated; it never places them by their day file.
-
-#### `event_sequence`
-- **Type**: number.
-- **Present**: conditional โ measured so far only on Claude Code's export
- route.
-- **Meaning**: a monotonic counter the tool emits alongside its events.
-- **If absent**: not carried by this tool's route.
-
-## Per-tool coverage
-
-Coverage is not uniform across tools, and it is not uniform across routes for the
-same tool. A tool absent from one route is not a zero for that route โ it is
-"not covered," and a consumer should print it that way rather than infer a zero
-from silence.
-
-| Tool | Export route (removed โ historical only) | Local-read route |
-| ---- | ------------- | ------------------ |
-| **Claude Code** | Was declared and measured: full request-level counters via `/v1/logs`, plus the six `"session"`-kind delta metrics via `/v1/metrics` every 10 seconds. `cost_usd` was only ever available through this route โ no local file carries it. Also the only route ever measured naming `billed_request_id` (its `request_id` attribute). | Declared and measured: complete token counters per assistant message, keyed on `requestId`. Step is stated by the tool itself (`attributionSkill`), exact per message โ the strongest attribution any tool or route offers. No `cost_usd`. Also names `billed_request_id` (the same `requestId`). **The one tool ever measured by both routes at once**: while the export route still ran, both routes could append a record for the same billed call under two different `turn_id`s (`prompt.id` there, `requestId` here) โ a consumer reading a day file spanning that period still collapses on `billed_request_id` before summing, or every figure doubles. See "A third way to double count." |
-| **Codex** | Was declared (`conversation.id` measured, zero-token, to verify the identifier only). Turn identifier and any metrics export stayed unmeasured โ no counters, no cost, ever flowed through this route. | Declared and measured: complete counters per turn, keyed on `turn_id`, from the rollout's `token_count` events paired with the preceding `turn_context`. A turn read while its session is still running is stored with whatever counters had arrived so far, and corrected โ never edited, a second line โ once a later read brings a larger reading of the same `turn_id`; see "A re-read appends unless matched." No tool-stated step โ attribution is only ever a run-journal interval, or unattributed. No `cost_usd`. |
-| **OpenCode** | Unmeasured โ no export payload has ever been captured for this tool. | Declared and measured, via `opencode export --sanitize`: counters per request (message), keyed on the message's own `id`. No established join to a run-journal entry โ no captured hook or plugin payload has ever carried OpenCode's own session identity, so nothing exists to join on; these figures answer only what a session consumed, alone. `info.cost` is deliberately never read: it is `0` in every message captured, and its denomination (which currency, computed vs. billed) has never been established โ a figure whose meaning is unknown is worse than an absent one. Records carry `event_timestamp` from the message's `time.created`, so they can be placed in a period; step attribution stays out of reach regardless, since there is no join to a run journal to attribute against. |
-| **Copilot** | Was declared (`gen_ai.conversation.id` measured, zero-credit, to verify the identifier only) โ but that attribute lived on the `invoke_agent` *span*, not on a log record or a metric, and the receiver only ever listened on `/v1/logs` and `/v1/metrics`. Limited to those two paths, it never saw the one attribute that identified a Copilot session, so this route yielded nothing in practice for as long as it existed. | Supported at **session** granularity only: `session.shutdown` in `~/.copilot/session-state//events.jsonl` carries input, output, cache read and cache write together, once, for the whole session. Nothing in its files counts a single request, so it yields a `session` record and never a `request` one, and no amount can be placed inside a step. Read `tokenDetails`, never `usage` โ the latter is inclusive of cache writes where every other reader's input is exclusive. No model is stamped: `currentModel` names the session's last model, not the one that spent. Separately, its file's own `cost` field is denominated in premium requests, not currency, so it could not be treated as `cost_usd` even where it is present. |
-| **Cursor** | Was unmeasured โ no payload was ever captured. Cursor's own documentation names `cursor.conversation.id`, but a name read from documentation is a guess, and enabling that export to verify it was a team setting on an Enterprise plan, in beta, that nobody outside a Cursor admin could turn on โ so it stayed declared unmeasured rather than declared from an unverified guess. | Unsupported (probed): Cursor writes no token count in any file it produces โ there is nothing on disk for a local reader to find. |
-
-Cursor was the one tool uncovered by both routes even before the export route
-was removed: its export was never enabled to measure, and its local files
-carry nothing to read.
-
-### Attributing records to a task
-
-A record carries no task identity, on any route. A task is derived by whatever
-reads the records, from two kinds of line the run journal records beside them.
-That derivation is deliberately not stored: a conclusion frozen at write time
-cannot be revised, while a derivation re-runs over every past session the day
-it changes.
-
-**A written file.** The journal hook reads a written path from the tool's own
-hook payload, and only Claude Code's carries one in a readable form: Copilot's
-and Cursor's were never captured doing so, and Codex writes through an
-`apply_patch` command string that would have to be parsed rather than read. A
-session whose journal names a written path this way belongs, as a whole, to
-whatever task that path resolves to.
-
-**A declared ticket.** `task_declared` records that a tool call's own
-arguments named a file under a task folder โ the same move `step_start`
-already makes for which skill is running, and it asks nothing of a payload's
-shape. It reaches every host the journal hook dispatches a tool-call event
-for, which today is every declared host, OpenCode included as of 2026-08-31:
-a bounded measurement (three further sessions, varying the model) found that
-a completed tool part's own arguments do reach its plugin's `event` hook, and
-`hooks/opencode-plugin.js` joins one the same way every other host's own hook
-already does โ see `scripts/__tests__/fixtures/README.md`, "OpenCode's tool
-part," for what was run and what arrived. A declaration is an interval, not
-a whole-session fact โ it opens where the tool call happened and
-closes at whichever of a later declaration or a turn boundary comes next, or,
-left open, at the last moment that session's journal actually recorded. Only a
-record whose own moment falls inside that interval belongs to the task by
-this route; the rest of the session falls back to whether it wrote into the
-folder, exactly as before.
-
-A session on a tool that produces neither kind of line is attributable to a
-**period** and, where a journal covers it, to a **step** โ but never to a
-task. A consumer prints that as a limit of the tool, exactly as it prints "not
-covered": a session with no task is not a session that touched nothing.
-
-The Copilot denomination is measured, though not from anything in this
-repository โ it comes from reading that tool's own session files, and is
-recorded here so the claim is auditable rather than taken on trust. Across
-fourteen local sessions, `modelMetrics..requests.cost` sits at `0.33`
-for every single-request `claude-haiku-4.5` session while `totalNanoAiu`
-ranges from 2.04 to 2.95 billion and output ranges from 46 to 154 tokens;
-a five-request `gpt-5-mini` session reads `0`. The figure tracks request
-count times a per-model multiplier and is invariant to consumption, which is
-what makes it premium requests rather than currency.
-
-## Consuming a session correctly
-
-To compute one session's true totals from a set of stored records:
-
-1. Group records by matching `tool` and `vendor_id` โ that pair names one real
- session, regardless of which `provenance` produced any individual record.
-2. Within that group, collapse every `kind: "request"`, `provenance: "local-read"`
- record sharing a `turn_id` into the one carrying the largest `input_tokens` +
- `output_tokens` + `cache_read_tokens` + `cache_creation_tokens` โ never a sum
- of the group, which would state a combination of counters the tool's own file
- never actually reported together. This is what a still-open turn re-read more
- than once (Codex, so far) leaves behind โ see "A re-read appends unless
- matched." Restricted to `kind: "request"` and `provenance: "local-read"`: a
- `"session"`-kind turn (Copilot's) is a one-shot total with nothing to
- collapse, and an export-route `turn_id` is a prompt id several distinct
- billed calls can share, so applying this step there would merge them.
-3. Then collapse every `kind: "request"` record sharing a `billed_request_id`
- into one before summing anything โ see "A third way to double count: one
- billed call, seen by both routes." A record with no `billed_request_id` is
- never collapsed with another. Order between steps 2 and 3 does not matter โ
- the two key on disjoint fields โ but doing step 2 first means a still-open
- local-read turn is already down to one record before it is ever compared
- against the export route's record for the same call.
-4. Sum `cost_usd`, `input_tokens`, `output_tokens`, `cache_read_tokens`, and
- `cache_creation_tokens` from the collapsed `kind: "request"` records in that
- group only. Never include `kind: "session"` records in this sum.
-5. Sum `active_time_s` from `kind: "session"` records in that group only โ no
- `"request"` record carries it.
-6. Do not key anything else on `turn_id` beyond what it is documented for here:
- it is a write-time match-and-correct key for local-read re-reads, not a
- unique identifier for a billed request. `billed_request_id`, not `turn_id`,
- is what step 3 above collapses on, precisely because `turn_id` lacks that
- guarantee.
-7. Where a tool's row above says a route is not covered, or covered without an
- amount, report that plainly rather than defaulting the missing figure to
- zero.
diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md
new file mode 100644
index 000000000..141982b3f
--- /dev/null
+++ b/aidd_docs/product/usage-contract.md
@@ -0,0 +1,283 @@
+# Usage contract
+
+> For: maintainers, hook authors, a later destination. What `aidd telemetry` records, stores and reports for Claude Code, release 1. How to use it: [plugin README](../../plugins/aidd-telemetry/README.md). Where the code is: [`cli/aidd_docs/memory/telemetry.md`](../../cli/aidd_docs/memory/telemetry.md).
+
+The code is the authority. A change to a format below changes this page in the same commit.
+
+| Part | Owns |
+| --- | --- |
+| CLI, `cli/src/contexts/telemetry/` | reading, storing, attributing, reporting |
+| Plugin hooks, `plugins/aidd-telemetry/hooks/`, plain Node | asking for a task, answering it, recording session facts |
+
+Only Claude Code is measured. The record admits other tools; no reader for them exists.
+
+```mermaid
+flowchart LR
+ T["Claude Code transcripts"] -->|"ingest"| L[("ledger")]
+ C[("consent log + git key")] -->|"gates storing"| L
+ H["hooks"] -->|"session facts, carries"| B[("bindings")]
+ H -->|"close lines"| C
+ K["aidd telemetry task"] -->|"declarations"| B
+ L --> R["report"]
+ B -->|"attribution"| R
+```
+
+## Usage record
+
+One record per billed model call, facts only: no cost, person, task or ticket. Those are derived once, at report time, for every tool. Type: `UsageRecord`, `cli/src/contexts/telemetry/domain/usage-record.ts`.
+
+| Field | Type | Meaning |
+| --- | --- | --- |
+| `tool` | `claude-code` \| `codex` | tool whose files were read |
+| `tool_version` | string \| null | version that wrote the line |
+| `key` | string | stable across re-reads, unique within `tool` |
+| `session_id` | string | the tool's session id |
+| `agent` | `main` \| `subagent` \| `advisor` | who made the call |
+| `at` | ISO-8601 UTC | when the call was recorded |
+| `model` | string \| null | model that billed it |
+| `input` | integer \| null | input tokens, cache read and write excluded |
+| `output` | integer \| null | output tokens, reasoning included |
+| `cache_read` | integer \| null | input served from cache |
+| `cache_write` | integer \| null | input written to cache, all lifetimes |
+| `cache_write_1h` | integer \| null | of which one-hour; `null` when not said |
+| `reasoning` | integer \| null | of which reasoning; `null` when not said |
+| `cwd` | string \| null | working directory, local only |
+| `git_branch` | string \| null | branch, local only; names the declaration to look up, never parsed |
+
+The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at ingest.
+
+**Null is not zero.**
+- Unknown is `null`, never `0`. A `null` counter is left out of its sum and counted in that counter's `unknown_records`.
+- A usage line missing a counter is still a record, that counter `null`.
+- A line that is no usage line (not JSON, no `message.usage`, no `message.id`, `sessionId` or `timestamp`) is never a record. An unrecognised shape counts in `coverage.unrecognised_shapes`.
+- The report says: "not recognised: a call missing a counter is kept with that counter unknown; a line that is no usage line is not counted".
+
+**Key and fold** (`domain/formats/claude-transcript-usage.ts`).
+
+| Rule | Value |
+| --- | --- |
+| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId:timestamp`; each advisor iteration adds `#advisor` |
+| Fold | a call is written several times (streamed snapshots, resumed sessions, sub-agents); one record per `(tool, key)`: largest sum of the four counters (`null` as `0` for this comparison only) |
+| Tie | earliest `at`, then lowest `session_id`, then serialised record. No input order changes the winner |
+| Dropped | model `` |
+| `cache_write` | summed over `message` iterations (the top-level split reflects only the first) |
+| Advisor | each `advisor_message` iteration is a record, `agent: advisor`, own model |
+| Sub-agent | `isSidechain` or an `agentId` |
+
+**Repository.** The call's working directory resolved at ingest: its git root, named by the hash of the `origin` address (`host/owner/repo`), else the root commit. The address is never kept.
+
+**Not stored.** Storing follows [Consent](#consent). A call read and left out counts in `coverage.not_stored` under one reason:
+
+| Reason | When |
+| --- | --- |
+| `outside-repo` | no git root, or a repository with neither origin nor root commit |
+| `never-seen-alive` | directory gone, never seen alive |
+| `no-consent` | clone had no consent at that time: not opted in yet, or turned off |
+| `consent-closed` | its live key names a closed interval; says "run `aidd telemetry on`", never says the clone has not opted in |
+| `unreadable-consent` | git config or `.git` unreadable, directory or git dir the file system will not let ingest look at, clone with no identity, damaged consent log |
+| `no-cwd` | no working directory |
+| `undated` | timestamp not a readable date |
+
+## Report envelope
+
+`aidd telemetry report --json` prints one object, `version: 1`. A field may be added to version 1; never renamed, retyped or removed without a new `version`. Type: `ReportEnvelope`, `cli/src/contexts/telemetry/application/report/report-envelope.ts`.
+
+| Field | Meaning |
+| --- | --- |
+| `version` | `1` |
+| `period` | `{from, to}`, UTC days, `null` for open |
+| `axis` | `total`, `person`, `session`, `model`, `agent`, `day`, `repository`, `task`, `ticket` |
+| `rows` | one per axis value |
+| `totals` | the whole period |
+| `unknown_records` | calls with at least one counter unknown |
+| `coverage` | `files_read`, `records`, `unrecognised_shapes`, `not_stored`, `consent_log_damaged`, `oldest_transcript_at`, `skipped_ledger_lines` |
+
+Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (each `{tokens, unknown_records}`), `total`. A row also has:
+
+| Field | Values |
+| --- | --- |
+| `kind` | `value`, `unattributed`, `absent` |
+| `key` | axis value for `value`, else `null` |
+| `reason` | `no-binding` or `declared-none` for `unattributed`, else `null` |
+
+- Every axis partitions the same calls: each adds up to `totals`, counter by counter.
+- `AIDD_TELEMETRY=0`: nothing read, object is `{"version": 1, "refused": "AIDD_TELEMETRY=0"}`.
+- A report ingests first. `files_read` and `unrecognised_shapes` describe that run; `records` the period.
+- No path, working directory or branch. `session` keys are full session ids, `repository` keys are hashes.
+
+## Stores
+
+Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XDG_CONFIG_HOME/aidd/telemetry`, else `~/.config/aidd/telemetry`. Empty means unset. CLI (`runtime/wiring/telemetry.ts`) and hooks (`hooks/lib/telemetry-dir.cjs`) share the rule, pinned by `scripts/__tests__/fixtures/telemetry-bindings/`. Created private.
+
+| Store | Writer | Readers | Holds |
+| --- | --- | --- | --- |
+| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by month of `at`. Only a changed month is rewritten, whole, by rename; no new bytes, no write |
+| `ledger/offsets.json` | `ingest`, `report` | same | per transcript: bytes consumed, size, file identity. Shrunk or changed identity: read whole |
+| `ledger/roots.json` | `ingest`, `on` | `ingest`, `forget` | `{version: 2, directories}`: per directory and per clone seen alive there, `repository_id`, `root`, `clone`, `seen_at`. Other format holds nothing: a live directory is remembered again, a gone one counts `never-seen-alive`. `on` records its own root for `forget` |
+| `ledger/consents.jsonl` | `on`, `off`, `ingest`, hooks (close only) | same, plus `forget` | consent intervals, see [Consent](#consent). Outlives the clone; removed with the ledger. Hooks parse it strictly, pinned by the shared fixture |
+| `ledger/.lock` | CLI | CLI | `{pid, created_at}`; dead process or older than ten minutes: cleared |
+| `bindings/.lock` | CLI | CLI | same format. Declarations and snapshots run under it; `task` never takes the ledger lock, which an ingest holds while reading transcripts |
+| `bindings/sessions.jsonl` | `task` | hooks, `report`, `task` | `{session_id, task, ticket, none, declared_at, by}`, `by` = `command` \| `hook-intercept` |
+| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with branch creation time from its reflog |
+| `bindings/carries.jsonl` | `SessionStart` hook | hooks, `report` | `{session_id, from, at}` |
+| `bindings/processes.jsonl` | `SessionStart` hook | `SessionStart` hook | `{pid, session_id, source, at}` |
+| `identity.json` | `identity` | `report` | `{"person_id": ""}`, owner-only |
+| git config `--local` `branch..aiddTask`, `aiddTicket`, `aiddDeclaredAt` | `task` | hooks, `task`, `report` via snapshots | branch declaration; follows a rename, leaves with the branch |
+| git config `--local` `aidd.telemetry` | `on`, `off`, `forget` | hooks, CLI | `2:` or `off`. Half of consent: alone it grants nothing |
+
+- A line not exactly its store's format is skipped, never guessed. Every JSON Lines store is append-only except the ledger.
+- `forget` removes `ledger/`, `bindings/`, `identity.json`, declaration keys and consent key in each recorded clone, and leftovers of an earlier measurement. Never the directory itself.
+
+## Attribution
+
+Task and ticket come only from `aidd telemetry task [--ticket ]` or `--none`. No branch name, folder, commit message or prompt is parsed. A ticket is free text, kept as typed.
+
+A declaration binds the running session (if `CLAUDE_CODE_SESSION_ID` is set) and, on a working branch, the branch. Never bound: the default branch (target of `origin/HEAD`, else `main` or `master`) and a detached `HEAD`.
+
+```mermaid
+flowchart TD
+ A["call"] --> S{"session declaration in force?"}
+ S -->|"yes"| T1["its task"]
+ S -->|"no"| C{"session carry?"}
+ C -->|"yes"| T2["carried task"]
+ C -->|"no"| B{"branch declaration?"}
+ B -->|"yes"| T3["branch task, any call since branch creation"]
+ B -->|"no"| U["unattributed, no-binding"]
+```
+
+- A declaration of no task is `declared-none`.
+- Declaring a branch late moves its earlier calls onto the task.
+- Sub-agent and advisor calls follow their parent session.
+
+## Ask
+
+`UserPromptSubmit` (`hooks/prompt-gate.cjs`) blocks a prompt before the model when all hold:
+
+| Condition | Test |
+| --- | --- |
+| Claude's own payload | `session_id` = `CLAUDE_CODE_SESSION_ID`, `transcript_path` under a `projects` directory |
+| clone measured | consenting per [Consent](#consent), `AIDD_TELEMETRY` not `0` |
+| person present | `CLAUDE_CODE_SESSION_ATTENDED=1` and `CLAUDE_CODE_ENTRYPOINT` is `cli` or `claude-desktop` (surfaces seen showing a hook message; an IDE extension is not asked, VS Code seen showing none) |
+| work undeclared | working branch, no declaration for session or branch |
+| CLI usable | `aidd` on `PATH` and knows `telemetry task` |
+
+- Anything else passes. A crash exits 0: the hook never fails a prompt.
+- Asks once per working branch; switching to an unbound branch asks again.
+
+Answering never blocks and never reaches the model:
+- `! aidd telemetry task ...` in the terminal fires no `UserPromptSubmit`.
+- `aidd telemetry task ...` typed as a prompt runs through the CLI with `--by hook-intercept`. Block reason: one line, what was declared, "Send your prompt again."
+- Grammar: a name, `--ticket `, `--none`, nothing else. Outside it nothing runs.
+- A one-line prompt starting like a declaration, only words and quoted strings, no `?`, `!`, comma or final full stop (`aidd telemetry task fix cart`) is blocked with the grammar and quoting hint, kept visible. Any other prompt starting with those words is ordinary.
+
+## Carry
+
+`/clear` and `/branch` (source `fork`) start a new session in the same process; the payload names no predecessor. The `SessionStart` hook (`hooks/session-start.cjs`) records every start.
+
+```mermaid
+sequenceDiagram
+ participant P as Claude process
+ participant H as SessionStart hook
+ participant B as bindings
+ P->>H: start, source and session_id
+ H->>B: append pid, session_id, source, at
+ alt source clear or fork, previous session of pid newer than boot, and bound
+ H->>B: append carry session_id, from, at
+ H-->>P: Task kept after /clear
+ end
+```
+
+- pid: `CLAUDE_PID`, else the hook's parent. Boot guard because process ids are reused across boots; it is the boot time only, not the process start time.
+- Message: `Task kept after /clear. Different work: aidd telemetry task [--ticket ]`.
+- `startup`, `resume`, `compact` carry nothing.
+- Provisional: the session's first declaration replaces it for the whole session, earlier calls included. An uncarried session is bound from its declaration on.
+
+## Consent
+
+> A call is stored only when all hold:
+> 1. its directory was seen alive belonging to clone X;
+> 2. X had an open consent interval at the call's time;
+> 3. `AIDD_TELEMETRY=0` is not set.
+
+Nothing else: no backfill, no first-`on` special case, no interval before its `on`, no consent flag elsewhere, no live-key condition on a live clone. **Measurement starts at `on`**; `on` reads nothing back. A transcript last written more than a day before the first interval opened is not read nor counted (the day absorbs a lagging file clock). Every clause is decided from observed facts, never from a path or a remote's look.
+
+```mermaid
+stateDiagram-v2
+ [*] --> Unmeasured
+ Unmeasured --> Open: on, key 2-token then open line
+ Open --> Closed: off, close line then key off
+ Open --> Closed: ingest or hook sees key not naming it
+ Open --> Closed: ingest sees another identity at path
+ Closed --> Open: on, fresh token
+```
+
+**Clone identity.**
+- Its git common dir: `dev`, `ino`, `birthtimeMs` of `fs.stat` on its real path, plus the path. Recorded in `roots.json` when first seen alive.
+- No identity (call `unreadable-consent`): `ino` `0` or missing, or any `stat` error other than not-found. One unreadable clone never aborts an ingest or report.
+- Another identity at a remembered path: the remembered clone is gone; the new one never answers for its calls.
+- Several clones at one directory: one entry each. A call goes to the latest clone born at or before it (birth time, else first seen), else the first. Owning a call does not store it: the owner's interval must cover it, and none opens before the clone's `on`.
+
+**Interval log** `ledger/consents.jsonl` (shared fixture, `consentLog` cases):
+
+| Line | Shape |
+| --- | --- |
+| open | `{"token": "", "clone": {"path", "dev", "ino", "birthtimeMs"}, "open": ""}` |
+| close | `{"token": "", "close": ""}` |
+
+- Opened once; a second open is ignored.
+- Several closes: the earliest wins, whatever line order (ingest and hooks both append).
+- Close of a never-opened token: no effect. Blank line: not damage.
+- Half-open: `on` at T covers T, `off` at T does not.
+
+**Who writes what.**
+
+| Actor | Does |
+| --- | --- |
+| `on` | under the ledger lock: fresh token, key `2:` first, then open line (a key with no interval measures nothing; an interval its key does not name would be closed by the first hook). Key naming an open interval of this clone: no change. Otherwise closes this clone's stale open intervals at its time. Refuses a clone with no identity |
+| `off` | under the same lock: close the clone's open token, then key `off`; key and interval change together, so an ingest never sees one without the other. Also closes the interval its key names at its path, whatever identity it reads there |
+| ingest | before judging any call, for every open interval, closes at observation time when the identity at its path changed or the live key is not `2:`. Nothing at the path: not closed (unmounted volume, folder moved and back); measured again when back. Unreadable config or `stat`: stays open, stores nothing |
+| hooks | after the Claude-only guard, even headless: key not naming an open interval recorded for this clone's real path => append a close at hook time. Ends a manual `git config aidd.telemetry off` or `--unset` before the next prompt's calls. `AIDD_TELEMETRY=0`: does nothing |
+| `forget --yes` | removes the log with the ledger; unsets the key in every recorded clone |
+
+**Consenting, for hooks and `task`.** All three: key is `2:`; log holds an open interval with it; that interval's clone is this one, by common-dir real path and by `dev`, `ino`, `birthtimeMs`, normalised as the CLI does (inode as text, missing birth as `0`, any other kept even if equal to change time, no inode = no identity; fixture `cloneIdentity` and `hookConsent` cases). Not consenting: a hand-set key (`2`, made-up token), a `cp -R` copy, a copy moved onto a deleted original's path, a clone remade at a path, a moved clone. There: nothing asked, nothing blocked, nothing closed that is not this clone's; run `aidd telemetry on`.
+
+**The key.**
+- `--local` writes the common config: shared by linked worktrees, never committed. A teammate who pulls is not measured, asked or blocked until their own `on`.
+- `.aidd/config.json` is never consent: not `telemetry: {enabled: true, version: 2}`, not the old bare `enabled: true`. Not read for consent.
+- Unreadable git config: `unreadable`, grants nothing, never rewritten.
+- A clone that never opted in: nothing stored, asked or blocked.
+
+**`on` also:** removes the old `telemetry` block from `.aidd/config.json` byte-for-byte otherwise, deleting the file only if that block was all (says the change may need committing); removes an earlier measurement's leftovers in the repository; records its clone for `forget`; warns when Claude Code's transcript retention is short; ends with the next step, declaring a task, which the hooks ask for in Claude Code. Does not reset offsets.
+
+**`forget --yes`** unsets `aidd.telemetry` and branch task keys in every clone in `roots.json` or `consents.jsonl` that still exists. Names only gone clones that had consented. A clone that never ran `on` is recorded only if a call from it was read after some clone opted in, so a hand-set key there may stay.
+
+**Fail closed.**
+- A terminated `consents.jsonl` line not exactly one of the two shapes: log untrusted. Storage refused for every clone; `on` and `off` refuse and change nothing; every `ingest` and `report` says so, `coverage.consent_log_damaged` in `--json`. Calls read meanwhile are lost (offsets move on).
+- An unterminated last line is not a line yet (cut write, or still writing): both readers ignore it, even if it parses. The next writer, CLI or hook, cuts it off before appending. A lost close is re-observed; a lost open leaves a key with no interval, measuring nothing.
+- Recovery: `aidd telemetry forget --yes`, then `aidd telemetry on` per clone. Safe: `forget` removes the log and offsets, no backfill, intervals open at `on`. Removing the line by hand is not recovery and is no longer suggested: a lost close would reopen a window turned off.
+
+## Residual limits
+
+None observable by the CLI.
+
+| Case | Effect | Mitigation |
+| --- | --- | --- |
+| Key changed by hand, then no hook or ingest before next `on` | interval stays open until observed; calls before observation covered, after turning off included | any session start, prompt or ingest observes it |
+| Machine clock | judged by transcript timestamp. Stamped inside an interval: covered whenever written; future-dated: covered by an open interval; outside: by stamp alone. File mtime more than a day behind its calls (set back, lagging network fs): not read. Copied transcripts: read only if written after first local `on`, stored only for a clone opted in here | - |
+| Worktree made and removed between two ingests | `never-seen-alive` | the session start inside it sees it alive |
+| No birth time | a clone reusing a deleted clone's path and inode is taken for it; of two at one directory, the later is told apart only from first seen | - |
+| Linux refuses `statx` (seccomp, kernel before 4.11) | birth = change time; identity moves on any git dir entry change, `on` included; clone stops being measured and asked | fails closed: `off` closes by key token at path |
+| Moved or renamed clone | new clone. Old interval open while path empty, closed when another directory appears; nothing stored or asked at new path until `on`. Back at same path, same identity: same clone, still measured | - |
+| Consent log edited by hand | outside the contract; can reopen a window `off` closed | tool says `forget --yes` |
+| Call read while git config, `.git` or git dir unreadable | `unreadable-consent`, offsets move on: not recovered on repair | - |
+| pid reused within one boot by a Claude process whose `startup` was never recorded (e.g. started before `on`) | can take the dead process's last session as predecessor, so `/clear` carries its task | announced, replaced by first declaration |
+| `AIDD_TELEMETRY=0` | stops hooks too; a key turned off meanwhile is observed at the next run without it | - |
+
+## Never leaves the machine
+
+Release 1 opens no network connection, in the telemetry context or the hooks. `forget --yes` removes everything.
+
+- Local only: ledger `cwd` and `git_branch`, roots, process facts, transcripts.
+- Text report: no path, branch, working directory or whole session id (short keys); envelope per [Report envelope](#report-envelope). `task`, `status`, `forget` print what they act on (a branch: `Branch feat/x is bound.`; `forget` lists entries and repositories); the lock error names the lock file.
+- Identity only if chosen with `aidd telemetry identity `; labels the `person` axis only.
+- No currency amount: the destination owns the price table.
diff --git a/aidd_docs/runs/.gitkeep b/aidd_docs/runs/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/aidd_docs/runs/README.md b/aidd_docs/runs/README.md
deleted file mode 100644
index 2be3e375f..000000000
--- a/aidd_docs/runs/README.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# aidd_docs/runs
-
-Where the run journal's records land once AIDD telemetry is turned on. This directory being present or committed is **not the permission**. The single authoritative switch is `.aidd/config.json`'s `telemetry.enabled`, read by `plugins/aidd-telemetry/hooks/journal.js` at the point of every write, never cached across a session. With that switch on, `aidd_docs/runs/` is created on demand if it does not already exist; with it off, no record lands here regardless of whether this directory exists. Records are ignored by git (see `.gitignore`), so cloning the repository never carries anyone's session history.
-
-## Shape
-
-One file per session: `__.jsonl`. One JSON object per line, appended and never rewritten โ a JSON object is a closed block that can only be rewritten whole, so this is what lets a session leave two hundred observations at the cost of two hundred appends instead of two hundred rewrites, and what lets a process that dies mid-write lose at most the one line it was writing.
-
-`schema_version: 2` on the `session_start` line. Version 1 was a single mutable ten-key object per session, rewritten on every turn (`ended_at`, `tasks[]`, `parent_run_id` among its fields) โ replaced because a value frozen at write time cannot be revised, and the hook was writing conclusions (an interval a task attached to, a session's end time) instead of observations.
-
-Every line carries `at` (ISO 8601, UTC, second precision) and `type`:
-
-| `type` | Carries | Fired by |
-| --- | --- | --- |
-| `session_start` | `schema_version`, `run_id`, `project_id`, `project_remote`, `tool`, `vendor_id`, `vendor_field`, plus `worktree_id` and `worktree_repo_id` when the session ran in a linked git worktree, plus `plugin_version` when the hook could read its own manifest | SessionStart |
-| `turn_end` | `prompt_id` when the host provides one, omitted otherwise | Stop |
-| `file_written` | `path`, repository-relative and `/`-separated, plus `source` (`"tool-stated"` \| `"observed"`) | PostToolUse, for a write that lands inside a task folder |
-| `task_declared` | `path`, repository-relative and `/`-separated | PostToolUse, for a call whose own arguments name a file under a task folder |
-| `step_start` | `skill` (sanitised as a value, never as a path segment), plus `turn_id` when the host provides one | PostToolUse, for a call that names a skill being run |
-| `step_end` | `skill`, sanitised the same way | PostToolUse, for a call whose own arguments carry `aidd:step-end ` โ the skill saying its own work is over, since no host emits that |
-| `scan_truncated` | `cap`, `scanned` | Stop, when the sweep for files a task folder gained since the turn began hit its budget before finishing โ so a reader can tell "nothing else changed" from "the walk gave up before it could tell" |
-
-`step_start` is the line that names which skill was running, and the one this table most
-recently caught up on documenting: complete coverage of what the journal writes is the
-whole reason a report can attribute a token to a step at all.
-
-`step_end` is its counterpart, and it exists because nothing else can supply it. A `Skill`
-tool call's own `tool_result` comes back about a tenth of a second later โ that is the
-dispatch, not the completion โ so no hook can observe when a skill finished. Without a stated
-end a reader must close a step at the next `step_start` or `turn_end`, and a `turn_end` is a
-pause: a skill working across three prompts is credited with its first turn and nothing
-after. So the skill declares its own end through a tool call it makes, carrying
-`aidd:step-end ` in that call's arguments, and the hook โ which alone holds the
-session id and the working directory โ writes the line. It is read out of free-form arguments
-exactly as `task_declared` is, so every host that forwards tool arguments is covered rather
-than one.
-
-The line names its skill, never only a moment. An end closes only the interval its own skill
-opened: closing "whatever is open" would close the wrong one the moment a skill invokes
-another, and an end naming a skill the session never started closes nothing at all rather
-than truncating whatever was running. Like `task_declared`, it restores the run file's mtime
-after appending, so it can never push the write watermark past a file the turn should still
-observe.
-
-`plugin_version` is this plugin's own version, from `.claude-plugin/plugin.json` โ read
-once per process by `lib/plugin-version.cjs`, never per line, and stamped only on
-`session_start`, the one line that names the session, never repeated on every later line.
-Never the framework's own version, and never the CLI's โ the CLI stamps a different field
-on the record it stores instead, `cli_version` (see `aidd_docs/product/metrics-contract.md`)
-โ two different producers, two different artefacts, two different values. A manifest this
-hook cannot read costs the version, never the line: `session_start` is written with
-`plugin_version` omitted rather than the hook failing, and a line written before this field
-existed is read the same way, as an unknown version, never as a default.
-
-`file_written`'s `source` says how the path came to be known. `"tool-stated"` is a path the
-host handed over directly โ exact, with no false positive. `"observed"` is a file that
-changed inside a task folder while the session was running, the only way a write made
-through a shell command or an `apply_patch` becomes visible at all โ and it can, in
-principle, catch a file something else on the machine wrote in the same window. A reader
-that must not risk that filters on this field.
-
-`worktree_id` is git's own name for the linked worktree the session ran in, and `worktree_repo_id` names the repository those worktrees share โ read from one `git rev-parse`, never from an agent runner's environment variable, which names that runner's concept rather than the repository's. A plain checkout, the common case, carries **neither key at all**: absent, never `null` and never `""`, because an empty string would gather every plain checkout into one group as though they were the same worktree.
-
-Neither `file_written` nor `task_declared` ever carries a `task_id`: task identity is a derivation from the path, and derivations belong to whatever reads the log, not to the hook that writes it. `task_declared` differs from `file_written` in what it takes as evidence, not in what it stores: a mention in a tool call's arguments (a read, an edit, a shell command line) rather than a payload naming a write outright - the move that reaches a task on a tool whose payload never hands over a written path at all. A reader turns a run of `task_declared` lines into a bounded interval, closed by whichever of a later declaration or a `turn_end` comes next; see `aidd_docs/product/metrics-contract.md`'s "Attributing records to a task".
-
-Whether any of these records is ever shared beyond the machine that wrote it is undecided. Nothing here sends anything anywhere.
-
-## The one marker outside a session file
-
-A payload that fires but matches no declared host writes a seventh, different kind of
-line: `{ "type": "unrecognised_payload", "at": ... }`, to its own fixed file,
-`_unrecognised.jsonl` โ never to a `__.jsonl`, since an unrecognised
-payload carries no session identity to name one by. That file holds at most one line,
-overwritten on every occurrence rather than appended to: it is a marker that this fired at
-all, not a log of how often. `aidd telemetry check` reads it to tell "a hook that never
-fires" apart from "a hook that fires from a host this build does not yet declare."
diff --git a/aidd_docs/tasks/2026_08/2026_08_26_cli-owns-the-read/plan.md b/aidd_docs/tasks/2026_08/2026_08_26_cli-owns-the-read/plan.md
index f3e986583..1fa50d09d 100644
--- a/aidd_docs/tasks/2026_08/2026_08_26_cli-owns-the-read/plan.md
+++ b/aidd_docs/tasks/2026_08/2026_08_26_cli-owns-the-read/plan.md
@@ -1,6 +1,6 @@
---
objective: "One implementation answers what work consumed โ the CLI โ while plain node keeps writing the journal, and the plugin says which half needs which."
-status: pending
+status: superseded
---
# Plan: The CLI owns the read path, node keeps the write path
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-1.md
index 4fa88211a..8b3005297 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-1.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-1.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: the sixth question gets its breakdown
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-2.md
index bb86b5637..f42b892a7 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-2.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-2.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: task capture stops being hand-written
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-3.md b/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-3.md
index d453a8b59..4f265c361 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-3.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_six-questions/phase-3.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: OpenCode stops being the tool with no capture
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_six-questions/plan.md b/aidd_docs/tasks/2026_08/2026_08_31_six-questions/plan.md
index dae4eebdb..049e9b107 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_six-questions/plan.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_six-questions/plan.md
@@ -1,6 +1,6 @@
---
objective: "A period breaks down by total, model, task, skill, person and project โ all six from the shipped command, each resting on a capture really taken from the tool it describes."
-status: pending
+status: superseded
---
# Plan: the core answers its six questions
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-1.md
index 149c70838..647fe3fd7 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-1.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-1.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: an interval reaches what the journal witnessed
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-2.md
index 59942a7c7..177ea8ac1 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-2.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-2.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: Codex declares from its own capture
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-3.md b/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-3.md
index 05f76845e..bf0d1bd68 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-3.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/phase-3.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: OpenCode's answer is measured, and bounded
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/plan.md b/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/plan.md
index 8f29715e3..7c79814e8 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/plan.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_task-attribution-completes/plan.md
@@ -1,6 +1,6 @@
---
objective: "Work following a task declaration is attributed while the session still runs, every unattributed record says which of three things happened, and every tool that can declare is captured."
-status: pending
+status: superseded
---
# Plan: task attribution completes
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-1.md
index d48d5690d..941f3aa2e 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-1.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-1.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: what would go, and what cannot
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-2.md
index 7e0c078d8..5e601d308 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-2.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-2.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: removing it, and saying what happened
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-3.md b/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-3.md
index 0cca21dd6..32773ce9d 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-3.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/phase-3.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: a person who asks is answered
diff --git a/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/plan.md b/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/plan.md
index da45f4415..8cdb8c58a 100644
--- a/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/plan.md
+++ b/aidd_docs/tasks/2026_08/2026_08_31_the-deletion-path/plan.md
@@ -1,6 +1,6 @@
---
objective: "A person removes what this tool measured about them, after being shown what will go, what will stay, and what history keeps whatever they do."
-status: pending
+status: superseded
---
# Plan: the deletion path
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_fewer-verbs/frame.md b/aidd_docs/tasks/2026_09/2026_09_01_fewer-verbs/frame.md
index 2a42574d0..e452194e3 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_fewer-verbs/frame.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_fewer-verbs/frame.md
@@ -1,3 +1,7 @@
+---
+status: superseded
+---
+
# Fewer verbs
## The ask
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-1.md
index fa63cfc5e..5f7abbfd1 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-1.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-1.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: the flow, read from what is already there
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-2.md
index bb5904995..3e85b693f 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-2.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-2.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: each producer stamps its own version
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/plan.md b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/plan.md
index ee3f70f0d..7e17f6e38 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/plan.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/plan.md
@@ -1,6 +1,6 @@
---
objective: "A period breaks down by the orchestrated run that produced it, derived from the journal as it stands, and every line names the version that wrote it."
-status: pending
+status: superseded
---
# Plan: the flow, and who wrote what
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/spec.md b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/spec.md
index 947c11882..789462e17 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/spec.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/spec.md
@@ -1,3 +1,7 @@
+---
+status: superseded
+---
+
# A period breaks down by the flow that ran, and every line says which version wrote it
## Target
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_one-place-per-nature/frame.md b/aidd_docs/tasks/2026_09/2026_09_01_one-place-per-nature/frame.md
index b5853480b..74f2429f8 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_one-place-per-nature/frame.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_one-place-per-nature/frame.md
@@ -1,3 +1,7 @@
+---
+status: superseded
+---
+
# One place per nature
## The ask
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-1.md
index d291dd502..c39a823fc 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-1.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-1.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: a folder says what it delivers
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-2.md
index db1865ca5..3f2937e0c 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-2.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-2.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: a period breaks down by it
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-3.md
index f9717dece..28da1a021 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-3.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-3.md
@@ -1,5 +1,5 @@
---
-status: pending
+status: superseded
---
# Instruction: the skills that open a folder write it
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/plan.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/plan.md
index 4acc56adf..969976bfb 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/plan.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/plan.md
@@ -1,6 +1,6 @@
---
objective: "A task folder names the backlog item it delivers, and a period breaks down by it, reconciling with every other breakdown."
-status: pending
+status: superseded
---
# Plan: the upward link
diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/spec.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/spec.md
index 4f234e0fd..2cab5d5a7 100644
--- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/spec.md
+++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/spec.md
@@ -1,3 +1,7 @@
+---
+status: superseded
+---
+
# A task folder says which backlog item it delivers, and a period breaks down by it
## Target
diff --git a/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/plan.md b/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/plan.md
index 2bde9bb9b..0f1bfd344 100644
--- a/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/plan.md
+++ b/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/plan.md
@@ -1,5 +1,5 @@
---
-status: planned
+status: superseded
backlog: ai-driven-dev/framework#703
spec: ./spec.md
---
diff --git a/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/spec.md b/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/spec.md
index ab21e2f7c..e226acfc3 100644
--- a/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/spec.md
+++ b/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/spec.md
@@ -1,5 +1,5 @@
---
-status: framed
+status: superseded
backlog: ai-driven-dev/framework#703
branch: fix/install-surfaces-agree
base: next@627408fb
diff --git a/aidd_docs/tasks/2026_09/2026_09_03_telemetry-at-scale/measurements.md b/aidd_docs/tasks/2026_09/2026_09_03_telemetry-at-scale/measurements.md
index a5a141972..8fdca6d55 100644
--- a/aidd_docs/tasks/2026_09/2026_09_03_telemetry-at-scale/measurements.md
+++ b/aidd_docs/tasks/2026_09/2026_09_03_telemetry-at-scale/measurements.md
@@ -1,3 +1,7 @@
+---
+status: superseded
+---
+
# The read path, met at a hundred sessions
What [#694](https://github.com/ai-driven-dev/framework/issues/694) asks to be written down
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/mutation-proofs.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/mutation-proofs.md
new file mode 100644
index 000000000..909c6fe75
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/mutation-proofs.md
@@ -0,0 +1,460 @@
+# Mutation proofs
+
+For each rule, the test that guards it, the one scripted change that breaks the rule, and what
+turned red. Each mutation was applied by script on the file named, the named tests were run,
+and the file was restored byte for byte.
+
+## Phase 3, count (`da23cff3`)
+
+| Rule | Test | Mutation | Red |
+| --- | --- | --- | --- |
+| Key with request id | "keys a call by message id and request id" | `:requestId` dropped from the key | yes |
+| Requestless key | "keys a requestless call by message id, session id and timestamp", "reads an empty request id as none" | timestamp dropped from the key | yes |
+| Synthetic dropped | "drops the synthetic model" | synthetic constant changed | yes |
+| Sidechain or agent id is a subagent | "calls a sidechain line a subagent", "calls a line carrying an agent id a subagent" | each check forced false | yes |
+| Cache writes summed over message iterations | "sums cache writesโฆ", "does not take the top-level cache writeโฆ" | top-level usage only | yes |
+| One-hour part unknown when a split is missing | "leaves the one-hour part unknown when any iteration lacks its split" | sum even when a split is null | yes |
+| Advisor iterations are their own records | "makes each advisor iteration its own recordโฆ" | key `#advisor` without index; wrong iteration type | yes |
+| Unrecognised shape reported | "reports a usage line of an unexpected shapeโฆ" | identity check disabled | yes |
+| Unknown counter is null, never 0 | "leaves a missing counter unknown, never zeroโฆ" | `?? 0` on counters | yes |
+| Fold: largest total, then earliest instant, then lowest session id | "keeps the record with the largest total", "breaks a tie on total by the earliest time", "compares times as instants", "breaks a tie โฆ by the lowest session id" | each comparison reversed | yes |
+| Order independence | "gives the same records whatever the order of the input" | final tie-break or output sort removed | yes |
+| Fold across files | "folds a key across filesโฆ" | key prefixed by file path | yes |
+| Recursive walk, set-asides included | "walks sub-agent directories at any depth", "finds a set-aside whatever its suffix" | no recursion; regex drops `.superseded-*` | yes |
+| Byte offsets, unterminated last line, shrunk or replaced file | "counts offsets in bytesโฆ", "does not consume an unterminated last lineโฆ", "reads a shrunk file whole", "reads a replaced file wholeโฆ" | read from 0; consume to end; size or identity check disabled | yes |
+| `CLAUDE_CONFIG_DIR` wins | "is projects/ under the configured directory when there is one" | always the config dir | yes |
+
+## Phase 4, store (`a80242cc`)
+
+| Rule | Test | Mutation | Red |
+| --- | --- | --- | --- |
+| Idempotent re-ingest, deterministic replacement | `stored-usage` "same record comes again", "better snapshot" | held-first order reversed; held always wins | yes |
+| Newline guard | snapshot store "crash left no newline" | guard removed | yes |
+| Stale lock: dead pid, age; live lock respected; released only by owner | `ledger-lock` tests | each check dropped; unconditional `rm` | yes |
+| Consent version, unreadable consent, `AIDD_TELEMETRY=0` | consent and ingest tests | version check dropped; unreadable as absent; refusal removed | yes |
+| Never seen alive not stored; persisted resolution after deletion | ingest tests | counted as outside-repo; persisted ignored | yes |
+| Remote variants give one id; root-commit fallback; lowest root commit; worktree same id | `repository-identity`, locator integration | normalisations dropped; fallback null; highest root; id from root path | yes |
+| Snapshot dedupe; creation time kept; lower-cased config keys | `branch-binding`, snapshot use case | always snapshot; carry-forward removed; lower-casing removed | yes |
+| Month partition by UTC instant; positions written last; unchanged partition untouched | `stored-usage`, ledger adapter, ingest | month from raw text; positions dropped; always rewrite | yes |
+| Owner-only dir and file | `private-storage` | chmod removed; mode 0644 | yes |
+
+## Phase 5, declare (`9be2a8f5`)
+
+| Rule | Test | Mutation | Red |
+| --- | --- | --- | --- |
+| Default branch and detached never bound | `branch-role`, declare use case, shared fixture | default check false; detached as working | yes |
+| Latest declaration wins from its own time, tie to the later line | `binding-resolution`, fixture `two-declarations-at-one-instant-the-later-line-wins` | `time <= at` dropped; `>=` to `>` | yes |
+| None declaration | resolution and `task-declaration` tests | `none` ignored; writer sets `none: false` | yes |
+| Snapshot at once; keys survive rename; stale ticket removed | declare use case tests | snapshot call removed; key prefix changed; unset skipped | yes |
+| Fixture field drift | fixture "is written with the fields the CLI writes" | writer emits `declaredAt` | yes |
+
+## Phase 6, ask (`165c7c0b`)
+
+| Rule | Test | Mutation | Red |
+| --- | --- | --- | --- |
+| Claude-only guard | claude-only fixture cases, Codex and Copilot payloads, Codex rollout under `projects/` | session-id equality, `projects` check or rollout rejection removed | yes |
+| Presence | presence matrix | `ATTENDED=1` or `sdk*` rule dropped; absent entrypoint accepted | yes, for the rule of the time. The `sdk*` rule is replaced by the surfaces seen: see "Presence, observed on each surface" |
+| ~~Consent~~ | ~~consent fixture, `AIDD_TELEMETRY`~~ | ~~version, env refusal or worktree fallback dropped~~ | superseded: this row described the deleted file-based consent (`.aidd/config.json`, a version). Consent is the clone's git config now: see Fix round 1 phase 6 and phase 8 |
+| Default and detached pass | branch-role fixture | forced `working` | yes |
+| Answer-path check | aidd absent, no `telemetry task` | `canAnswer` always true | yes |
+| Strict intercept | 22-prompt injection test | forbidden-character, unknown-option or word-only check removed | yes |
+| Clear and fork carry | clear, chain, other pid, unbound, pid before boot | pid ignored; bound check skipped; boot guard dropped | yes |
+| No carry on resume, startup, compact | source matrix | those sources carried | yes |
+| Silent catch-up | catch-up test | stdout printed; `--quiet` dropped | yes |
+| Declaration suppressed and marked | typed-declaration test | `suppressOriginalPrompt` false; `--by` removed | yes |
+
+## Phase 7, attribute and report (`cbc2ceb9`, `a53833d4`)
+
+| Rule | Test | Mutation | Red |
+| --- | --- | --- | --- |
+| Session declaration from its time; mid-session redeclaration | `attribution.unit.test.ts` "applies a later declaration from its own time only" | time ignored; carried floor uses latest declaration | yes |
+| Carried session moved whole by its first declaration | "is moved whole by its first declaration, the work before it included" | floor ignores declarations | yes |
+| Carry covers the whole carried session | fixture `carried-before-the-carry-falls-to-branch` | carry floor โโ | yes |
+| Branch binds after creation; declared after the work; repository and name | attribution branch tests | creation check removed; from declaration time; name-only lookup | yes |
+| Reused branch name keeps generations | "keeps the work of an earlier branch of that name on its own task" | latest snapshot only | 7 red |
+| `declared-none`; session beats branch; subagent inherits | attribution tests | none as no-binding; order swapped; session lookup skipped | yes |
+| Unknown counter never summed as zero | `usage-report.unit.test.ts` | null no longer counted | yes |
+| Every axis reconciles | `usage-report.property.unit.test.ts` | ticket rows without ticket dropped | yes |
+| Identity shape; oldest transcript date | identity and ingest tests | one-field rule relaxed; result null | yes |
+
+## Phase 8, opt in, opt out, forget (`8f9a757f`)
+
+| Rule | Test | Mutation | Red |
+| --- | --- | --- | --- |
+| Hook line and delegate go together | lifecycle "keeps the script while a lefthook job still calls itโฆ", husky variant | `callers.length === 0` to `>= 0` | 2 red |
+| Foreign hook content byte for byte | "keeps a hook's other content byte for byte, and its mode" | lines trimmed on rejoin | yes |
+| Forget preview changes nothing | "previews and changes nothing on disk, not a byte nor an mtime" | `!confirmed` to `confirmed === undefined` | yes |
+| ~~Offsets reset on `on`~~ | ~~"is stored once opting in, though ingest had read past it"~~ | ~~`resetPositions()` removed~~ | superseded by fix round 4: `on` reads nothing back and resets no offsets; the test now asserts the opposite |
+| ~~`on` writes version 2; `off` keeps version~~ | ~~switch tests~~ | ~~`version` dropped; spread dropped~~ | superseded: the file-based switch is deleted. `on` writes `aidd.telemetry=2` and `off` writes `off` in git config: see Fix round 1 phase 8 |
+| ~~Remembered roots re-resolved~~ | ~~"is stored for a directory that is gone, whose refusal was remembered"~~ | ~~`resolutions.save` removed~~ | superseded by fix round 2: `on` rewrites no remembered refusal, a deleted directory is judged by its clone |
+
+## Phase 9, skills and journey (`2486849d`, `e6b04615`)
+
+| Rule | Test | Mutation | Red |
+| --- | --- | --- | --- |
+| Skills name only real commands, flags, axes | `telemetry-skills-name-real-commands.test.js` | `report` to `reprt`; `--yes` to `--yess`; `--axis tasks`; `--axis tickets` in `frame.md` | yes, each |
+| Skills never name hooks or V1 | same test | a "hook" word added | yes |
+| Journey: carry, presence, gate, carries in report | `telemetry-journey.e2e.test.ts` | carry never written; presence false; gate skips presence; carries ignored | yes, each |
+
+Phase 8 rows were later revised by the consent move (fix round 1): consent is git config now, and its mutations are below.
+
+## Fix round 1, phase 4 (store, ingest, consent)
+
+| Rule | Test | Mutation | Result |
+| --- | --- | --- | --- |
+| Partitioning is linear in the records | `stored-usage.unit.test.ts` "copies each record a bounded number of times" (counts array iteration, deterministic) | `partitionByMonth` copies the month array per record again (`[...(held ?? []), record]`) | 1 red, that test (2,003,001 elements touched at n=2000 before the fix) |
+| An upsert names the months it changed, the old month of a replaced record included | `stored-usage.unit.test.ts` "names the months it changed" | loop over `[record]` instead of `[record, was]` | 1 red, that test |
+| Ingest tells the ledger which months to write | `ingest-usage-use-case.unit.test.ts` "rewrite only the month a new call falls in" | `save(merged.records)` without the months | 1 red, that test |
+| A save told the months reads and writes only those | `usage-ledger-adapter.integration.test.ts` "rewrites only the months it is told changed" | the filter by told months removed | 1 red, that test |
+| A snapshot reads and appends under the bindings lock | `snapshot-bindings-use-case.unit.test.ts`, `bindings-lock-adapter.integration.test.ts` "leaves one snapshot when ... at once", `declare-task-use-case.unit.test.ts` | `append` called without `lock.exclusively` | 3 red: the snapshot lock order, the concurrent-snapshot test (3 writers, more than 1 snapshot), the declare order |
+| A declaration's session line is appended under the bindings lock | `declare-task-use-case.unit.test.ts` (event order, default-branch order) | `sessions.append` outside the lock | 2 red, those tests |
+| A declaration never waits on the ledger lock | `telemetry-task.e2e.test.ts` "declares at once while an ingest holds the ledger lock" | not mutated: before the fix the test timed out at 5 s (declaration blocked, 60 s lock wait); the declare use case no longer receives the ledger | red on the pre-fix binary, green after |
+| ~~`on` lifts a remembered refusal by repository, a deleted linked worktree included~~ | ~~`resolution-consent.unit.test.ts`, `telemetry-switch-use-cases.unit.test.ts`~~ | ~~`ofRepository = false`~~ | superseded by fix round 2: the lifting by `repository_id` leaked consent across clones and is deleted |
+| ~~`on` passes the repository id~~ | ~~same use-case test~~ | ~~`repositoryIdOf(located)` replaced by `null`~~ | superseded by fix round 2, with the lifting |
+| A declaration is read at the call's time within a branch generation, the first one covering from creation | `attribution.unit.test.ts` three new "redeclaration" tests | `generationAt` returns `generation.at(-1)` (latest snapshot) | 5 red (recorded 3 before; re-run in fix round 2 over `tests/contexts/telemetry`: the three new redeclaration tests, "speaks with the latest snapshot of one declarationโฆ" and "lets a snapshot with no declaration time cover from the creationโฆ") |
+
+## Fix round 1, phase 6 (hooks)
+
+| Rule | Test | Mutation | Result |
+| --- | --- | --- | --- |
+| A prompt that only starts like a declaration, or holds shell syntax, goes to the ordinary gate and spawns nothing | `aidd-telemetry-hooks.test.js` "injection" and "only starts like a declaration" | an intercepted prompt that does not parse blocks "not understood" again | 2 red (since narrowed by fix round 2: only a prompt that attempts a declaration is told so) |
+| The `cmd.exe /s` line has an outer pair of quotes | same file, "cmd.exe /s command line" | `cmdLine` returns `"shim" args` | 1 red |
+| The hook's home is the OS home, as the CLI's | same file, "the telemetry directory's home" | `home ?? set(env.HOME) ?? os.homedir()` | 1 red |
+| A committed `.aidd/config.json` is not consent | same file, "a committed .aidd/config.json is not consent" | the hook also accepts a committed `telemetry.version === 2` | 1 red |
+| `AIDD_TELEMETRY=0` refuses before git is asked | same file, "AIDD_TELEMETRY=0 refuses" and the session-start test | the environment check removed | 2 red |
+
+## Fix round 1, phase 8 (consent, leftovers, forget)
+
+| Rule | Test | Mutation | Result |
+| --- | --- | --- | --- |
+| Nothing in `.aidd/config.json` is consent (CLI) | `git-consent-adapter.integration.test.ts`, `telemetry-lifecycle.integration.test.ts` | the adapter falls back to a committed `telemetry.version === 2` | 2 red |
+| `AIDD_TELEMETRY=0` refuses (CLI) | `declare-task-use-case.unit.test.ts`, `ingest-usage-use-case.unit.test.ts` | each refusal check removed in turn | 1 red each |
+| `on` removes the previous version's block, and deletes the file only when emptied | `telemetry-switch-use-cases.unit.test.ts`, `telemetry-lifecycle.integration.test.ts` | the legacy cleaning replaced by "none" | 6 red |
+| `off` writes `off` | `telemetry-switch-use-cases.unit.test.ts` | the write removed | 1 red |
+| `forget` unsets the consent key | `repository-declarations-adapter.integration.test.ts` | the consent key left out of the unset loop | 1 red |
+| `forget` finds a repository that only opted in | `forget-telemetry-use-case.unit.test.ts` | consented resolutions no longer added | 1 red |
+| `on` ends with the next step | `telemetry-lifecycle-display.unit.test.ts` | "Next:" line reworded | 1 red (after the test was tightened: the first version of the test survived this mutation) |
+| The unknown-counter message says what happens to the call | the two display tests | message back to "not recognised and not counted" | 2 red |
+
+Rows before fix round 1 record "yes" and no count: they were not counted then, and are not re-run here. Only the row corrected above was re-run.
+
+Not mutated: removing `outside-repo` and `root-unresolved` from the unattributed reasons (a
+type-level change: the exhaustive `Record` makes a reinstated reason fail the
+typecheck), and the documentation corrections.
+
+## Fix round 2
+
+Every row below was re-run at the final HEAD of the round: the mutation applied by script to the
+file named, the scope run (`npx vitest run tests/contexts/telemetry tests/presentation` plus
+`tests/contexts/tools` and `tests/contexts/framework/application/plugin` for the Codex row, and
+`node --test scripts/__tests__/aidd-telemetry-hooks.test.js` for the hook row), and the file
+restored (checked equal to the original by the script). Counts are failing tests.
+
+| Rule | Test | Mutation | Result |
+| --- | --- | --- | --- |
+| `on` lifts nothing: a clone's consent never reaches another clone sharing the remote or the root commit | `telemetry-switch-use-cases.unit.test.ts`: (b) "keeps refusing a deleted clone of the same remote that never opted in", (c) "keeps refusing a clone of the same remote that ran off, deleted or not" and "keeps refusing what a clone made while off, though it ran on first and was then deleted", (e) "keeps refusing a deleted copy with no remote that shares the root commit", and "resets the offsets โฆ rewrites no refusal" | `rememberOwnRoot` also marks every remembered resolution of its `repository_id` consenting (the lifting reintroduced) | 5 red, those five. Before the fix (b), (c) and (e) were red for the same reason |
+| A deleted directory is judged by its clone's git config read live; the remembered flag only once the clone is gone | `ingest-usage-use-case.unit.test.ts` "a directory that is gone is judged by the clone it belonged to"; `telemetry-switch-use-cases.unit.test.ts` "stores a deleted linked worktree of the clone that opted in"; `telemetry-lifecycle.integration.test.ts` "is stored for a directory that is gone, whose refusal was remembered" | `fromMemory` always takes the clone as gone (remembered flag only) | 6 red |
+| `off` withdraws what was remembered of its own clone, so a deleted clone is not judged by a stale yes (my reading of "the remembered consent is used only when the clone is gone": the memory has to be right for that case) | `telemetry-switch-use-cases.unit.test.ts` "keeps refusing what a clone made while off, though it ran on first and was then deleted", "withdraws what was remembered of the clone's other directories, and of no other clone", "writes under the ledger's lock โฆ" | the `forgetConsentOfClone` call removed from `off` | 3 red |
+| `off` lowers the clone's own entries only | same file, "withdraws what was remembered of the clone's other directories, and of no other clone" | the clone check removed in `forgetConsentOfClone` | 1 red |
+| `on` remembers its own root, so `forget` finds the clone though no session ever ran there | `telemetry-switch-use-cases.unit.test.ts` "remembers its own root, seen alive, so forget can find the clone later"; `telemetry-lifecycle.integration.test.ts` "unsets the consent of a clone where `on` ran and no session ever did, and of one that ran off" (the in-process twin of the e2e "leaves no consent behind in a clone where `on` ran and no session ever did") | `rememberOwnRoot` records nothing | 5 red, the integration twin among them. The e2e on the built binary was not run against this mutation |
+| `forget --yes` clears the consent of every clone recorded, those `on` recorded included | `forget-telemetry-use-case.unit.test.ts` "clears the consent of a clone `on` remembered, though no session ever ran there" and the rest of the clone-keyed suite | forget skips the clones recorded as consenting | 12 red (the whole clone-keyed suite builds its clones that way) |
+| `forget` locates a directory remembered before clones were recorded whatever consent it found | `forget-telemetry-use-case.unit.test.ts` "finds the clone of a directory remembered as refusing before clones were recorded, whose key says off" | a legacy entry is skipped unless it consented | 1 red |
+| A mistyped declaration is blocked with the grammar and the quoting hint, and runs nothing | `aidd-telemetry-hooks.test.js` "a declaration mistyped is blocked with the grammar and the quoting hint, and runs nothing" | `attemptsDeclaration` never consulted (always fall through) | 1 red, that test; the injection test and "only starts like a declaration" stay green |
+| Ingest rewrites a month that holds a line that is not a record | `ingest-usage-use-case.unit.test.ts` "also rewrites a month that holds a line that is not a recordโฆ", "repairs a damaged month even when every call it read was already held" | `damagedMonths` left out of the months saved | 2 red |
+| The branch config is read inside the bindings lock | `snapshot-bindings-use-case.unit.test.ts` "reads the branch config, the latest snapshots and appends โฆ inside the bindings lock" | the config read once, before the lock, and passed in | 1 red |
+| Each lock names itself in its timeout message | `bindings-lock-adapter.integration.test.ts` "names itself, and its own file, when it times out" | the bindings adapter stops passing its name | 1 red |
+| The `total` axis shows its row once | `telemetry-report-display.unit.test.ts` "shows the whole once on the total axis, and a Total row under every other axis" | the `Total` row always added | 1 red |
+| The Codex trust notice no longer speaks of a run journal | `codex.unit.test.ts`, `plugin-add-mcp.unit.test.ts` | the old sentence restored | 3 red |
+| An unreadable consent is worded as the clone's git config | `telemetry-display.unit.test.ts` "words an unreadable consent as the clone's git configโฆ" | the old `.aidd/config.json` wording restored | 1 red |
+| `on` and `off` speak of the clone | `telemetry-lifecycle-display.unit.test.ts` "says measurement is on for this clone, โฆ" | "this repository's linked worktrees" restored | 1 red |
+| (re-run) a reused branch name keeps the declaration in force at the call | `attribution.unit.test.ts` "a branch name used again" | `generationAt` returns `generation.at(-1)` | 5 red (the figure the review observed; the 3 recorded before was wrong) |
+
+Not mutated: the help wording (`aidd telemetry on`, `off`, `ingest`) is pinned by the help golden
+(`help-surface.e2e.test.ts`); `target.md`, `codebase-map.md`, `telemetry.md`, `usage-contract.md`
+and the comment rewording are documentation.
+
+Not seen red before the fix, proved by mutation afterwards: the forget tests, the snapshot-in-lock
+test, the unreadable-consent wording test and the Codex notice (the test and the wording were
+changed together). Seen red first: the `on`/`off` wording test, the resolver and `on` tests ((b),
+(c), (e), the worktree), the `off` tests, the legacy forget test, the hook test, the torn-line
+tests, the lock-name test and the duplicate-row test.
+
+## Fix round 3
+
+One predicate, one table. `cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts` runs real git repositories in temporary directories, the real adapters and a real ingest, and states the calls the ledger ends up holding. It was written first, against the code of `309f7393` and nothing newer (only `wireTelemetry` and each use case's `execute`).
+
+Before the fix, per row (`npx vitest run โฆ --reporter=verbose` at `309f7393`):
+
+| Row | Scenario | Before the fix | After |
+| --- | --- | --- | --- |
+| 1 | (b) deleted clone that never opted in, then `on` in another clone of the remote | pass (fixed in round 2) | pass |
+| 2 | (c) clone that ran `off`, then `on` in another clone | pass (fixed in round 2) | pass |
+| 3 | (e) deleted copy, no remote, root commit shared, carrying the source's consent | **fail**: `c1` stored while the copy was alive | pass |
+| 4 | deleted linked worktree seen alive before `on`, then `on` in its clone | pass | pass |
+| 5 | same repository cloned again at the path, then `on`: the old clone's deleted worktree | **fail**: `pw` stored | pass |
+| 6 | unrelated repository cloned at the path, then `on`: the old clone's deleted worktree | **fail**: `pw` stored | pass |
+| 7 | the old clone's own calls, cloned again at the path, `on` there | **fail**: `po` stored | pass |
+| 8 | `on`, calls, `off`, calls, `on`, calls, ingest after each batch | **fail**: the middle call stored | pass |
+| 8b | the same, one ingest at the end (added: the result must not depend on ingest timing) | **fail**: the middle call stored | pass |
+| 9 | `on`, a manual `off` an ingest observes, calls, clone deleted | **fail**: the call after the observation stored | pass |
+| 10 | first `on`, history from before it | pass | pass |
+| 11 | `AIDD_TELEMETRY=0` | pass | pass |
+| 12 | `forget --yes` after `on`, no session | **fail**, on its precondition: `on` wrote no `ledger/consents.jsonl`. The `forget` and the two assertions after it were not reached at the old code; the file assertion could only have passed vacuously there, the file never existing | pass |
+
+Rows 1, 2, 4, 10 and 11 guard behaviour that was already right: they stay green, and the mutations below show they can go red. Every "not stored" row holds a positive control, a call that must be stored, so none passes because ingest stored nothing. A clone "deleted" in a row is moved aside: a deleted directory's inode may be given to the next one on some file systems, and a row about a clone told apart from its successor must not depend on that.
+
+Only the table was written before the code. The unit and integration tests under it were written with the code and are proved by the mutations below, run afterwards.
+
+Every row below: the mutation applied by script with an exact-anchor check, `npx vitest run tests/contexts/telemetry tests/presentation` (the Node suite for the hook row), the file restored and checked byte-equal. Counts are failing tests.
+
+| Rule | Mutation | Result |
+| --- | --- | --- |
+| A clone is told from another at its path by its identity | identity ignored, read by path only (`sameClone` and `cloneKey` compare the path alone) | 22 red, including rows 5, 6, 7 |
+| A call is judged at its own time | the intervals ignored: any `on` of the clone covers every call | 13 red, including rows 2, 8, 8b, 9 |
+| A call made while off is never stored | the off window included: an `off` closes nothing | 19 red, including rows 2, 8, 8b, 9 |
+| The end of an interval is excluded | `at <= to` | 4 red |
+| The start of an interval is included | `from < at` | 3 red |
+| Fail closed when the platform gives no inode | the `ino === 0n` check removed in `identityFromStat` | 1 red: `clone-identity.unit.test.ts` "is none where the platform reports no inode". No table row reaches it: a real macOS stat never returns an inode of 0, so the check is pinned at the seam, and the locator and the consent adapter take an identity reader that the tests make blind |
+| Ingest looks at every clone whose consent is open | `observeOpenConsents` not called | 3 red, including row 9 |
+| A clone found gone ends its consent | the close on `gone` removed | 2 red |
+| A clone whose key is not `2` ends its consent | the close on `absent` removed | 4 red, including row 9 |
+| A live clone whose key is not `2` stores nothing, whatever its intervals | `state === "absent"` no longer refuses: live key not required | 8 red |
+| The latest clone born by the call answers for a directory | always the latest | 6 red, including row 7 |
+| The first clone answers for a call older than every clone | always the first | 4 red, including row 7 |
+| `off` ends the interval | the close removed | 6 red, including rows 2, 8, 8b |
+| `on` opens the interval | the open removed | 23 red, all thirteen rows |
+| `forget` names a gone clone only if it consented | any gone clone named | 2 red |
+| Ingest saves the damaged month (round 2's weak test, now asserting which months were saved) | `damagedMonths` dropped from the saved set | 2 red: "repairs a damaged month even when every call it read was already held" and "also rewrites a month that holds a line that is not a record". Observed with the new assertion on the saved months taken out of the test and the same mutation: 1 red, the second test only, which is the review's own line "M5: `damagedMonths` dropped from the saved set only gives 1 red (recorded 2; see ๐ข test gap)" |
+| A mistyped declaration says how to send it as an ordinary prompt | the message line removed | 1 red: "a declaration mistyped is blockedโฆ". A second test, "a question mark makes it an ordinary prompt", checks that the way out the message gives works; it is not mutation-proved, because dropping `?` from the `attemptsDeclaration` regex leaves it green (the tokeniser already refuses a `?`) |
+
+Survivors left in the files of this round, none believed to be a gap: `clone-identity-reader.ts` `{ bigint: true }` (an inode above 2^53 cannot be made on the machine this ran on), `clone-identity.ts` `<= 0n` against `< 0n` (equivalent: 0 maps to 0), `birthtimeMs` type check against the finite check (equivalent), `directory-resolver.ts` filter of the current owner (the owner list is the same set), `""` fallbacks on a missing file.
+
+Mutation score of the `telemetry` scope at the head of the round: 96.4 (3,343 detected, 126 undetected), floor 96.
+
+Not mutated: `usage-contract.md`, `target.md`, `codebase-map.md`, `telemetry.md` and the comments; the per-OS notes on the identity in `clone-identity.ts` (macOS observed; Linux and Windows read from Node's `fs.Stats` documentation and libuv's `src/unix/fs.c`, `src/unix/linux.c` and `src/win/fs.c`, v1.x, and not run).
+
+## Fix round 4
+
+The user decided: `on` no longer catches up history, and a call is stored only if its clone's consent was open at the call's time. The rule is in `usage-contract.md` ยง Consent, written first. Consent is now a key `2:` together with an interval in `ledger/consents.jsonl` (`{token, clone, open}` and `{token, close}`), closed by `off`, by ingest and by the hooks.
+
+### The table, before and after
+
+`consent-scenarios.integration.test.ts` was extended before any code, using only `wireTelemetry`, each use case's `execute` and the real `prompt-gate.cjs`, so it ran at `d67ed04e` (`npx vitest run tests/contexts/telemetry/consent-scenarios.integration.test.ts --reporter=verbose`). Rows 1, 2, 4 and 9 had positive controls dated before their `on`; under the new rule those controls are no longer stored, so each control was moved after its `on` (row 4 was reshaped: the worktree is seen alive after `on`, then removed, and its next call is judged by the remembered clone). Row 10 flips, and 10b (the same, never ingested before `on`) was added. The hook rows run the real `prompt-gate.cjs` with a stand-in `aidd` that answers `telemetry task --help`, so a row that expects no block holds a control that does block.
+
+Before the fix, 13 rows failed and 13 passed. After it, all 26 pass.
+
+| Row | Scenario | Before | After |
+| --- | --- | --- | --- |
+| 1 | (b) deleted clone never opted in, then `on` in another clone of the remote (control after `on`) | pass | pass |
+| 2 | (c) clone that ran `off`, then `on` in another clone (control after `on`) | pass | pass |
+| 3 | (e) deleted copy, no remote, root commit shared, carrying the source's consent | pass | pass |
+| 4 | worktree of an opted-in clone seen alive, removed before the next ingest | pass | pass |
+| 5 | same repository cloned again at the path, then `on`: the old clone's deleted worktree | pass | pass |
+| 6 | unrelated repository cloned at the path, then `on`: the old clone's deleted worktree | pass | pass |
+| 7 | the old clone's own calls, cloned again at the path, `on` there | pass | pass |
+| 8 | `on`, calls, `off`, calls, `on`, calls, ingest after each batch | pass | pass |
+| 8b | the same, one ingest at the end | pass | pass |
+| 9 | `on`, a manual `off` an ingest observes, calls, clone deleted (control after `on`) | pass | pass |
+| 10 | history before the first `on` is **not** stored (changed) | **fail**: `a0` stored | pass |
+| 10b | the same, never ingested before `on` | **fail**: `a0` stored | pass |
+| 11 | `AIDD_TELEMETRY=0` refuses everything | pass | pass |
+| 12 | `forget --yes` after `on`, no session | pass | pass |
+| h1 | old clone never ingested while it lived, re-clone of the same repository at its path, `on` | **fail**: `p1` stored | pass |
+| h1b | the same, an unrelated repository at the path | **fail**: `p1` stored | pass |
+| h1d | outside any repository refused, `git init`, `on`: nothing comes back | **fail**: `d1` stored | pass |
+| h15 `off` | `on`, c1, manual `git config aidd.telemetry off`, a headless hook prompt, c2, `on`, c3, one ingest: c1 and c3 stored, c2 not | **fail**: c2 stored | pass |
+| h15 `--unset` | the same with `--unset` | **fail**: c2 stored | pass |
+| h15b | the same with no hook between: c2 stored, a documented residual limit | pass | pass |
+| h2 | `off`, the clone moved, `on`: calls made while off not stored | **fail** | pass |
+| reverse | `on`, c1, `off`, one ingest at the end: c1 stored | **fail**: nothing stored | pass |
+| reverse 2 | on/off/on/off, one ingest at the end: c1 and c3 stored | **fail** | pass |
+| forget-on | `forget` then `on`: earlier calls not stored | **fail** | pass |
+| hand key | key set by hand (`2` and `2:forged`) and a `cp -R` copy: nothing stored, the real gate does not block; the opted-in original stores and blocks | **fail**: the gate blocked the hand-set clones | pass |
+| damaged | a damaged line in `consents.jsonl`: nothing more stored, `unreadable-consent` counted | **fail**: `c2` stored | pass |
+
+Every "not stored" row holds a positive control (a call that must be stored), so none passes because ingest stored nothing. The "before" column of rows 10 and later is as observed; it was not re-derived from a diagnosis. Rows 1 to 9, 11 and 12 pass on both sides because they guard behaviour that was already right (their mutations below show they can go red).
+
+### Mutations
+
+Each applied by script with an exact-anchor count of 1 (`scratchpad/mut.py`), running `npx vitest run tests/contexts/telemetry tests/presentation` and `node --test scripts/__tests__/aidd-telemetry-hooks.test.js`, then the file restored and checked byte-equal. Counts are failing tests, vitest then node.
+
+| Rule | Mutation | Result |
+| --- | --- | --- |
+| The hook needs an interval, not the key alone | `decideConsent` grants on the key's token alone (intervals ignored; any key counts) | 1 + 2: row "hand key"; the fixture's `hookConsent` cases, "a cp -R copy carries the keyโฆ" |
+| A call is judged at its own time | the CLI `covers` ignores the time of an interval | 34 + 0 |
+| A call made while off is never stored | `off` closes nothing (off window included) | 9 + 0, including rows 2, 8, 8b, h2, reverse, reverse 2 |
+| The hook ends an interval its key no longer names | the hook's close not written | 2 + 2: rows h15 `off` and `--unset`; "a clone whose key was turned off by hand has its interval closedโฆ", "the session start and the catch-up close it too" |
+| The hook knows its clone by real path | the real path check removed in `decideConsent` | 1 + 2: row "hand key" (the `cp -R` copy); the fixture; "a cp -R copy carries the keyโฆ" |
+| A damaged log refuses storage | the CLI's damaged check removed | 2 + 0: row "damaged"; "stores nothing for any clone while a line of the consent log is damaged" |
+| A damaged log grants the hook nothing | the damaged check removed in `decideConsent` | 0 + 1: the fixture's `hookConsent` cases only. The behaviour test stays green because `consentGranted` refuses a damaged log before `decideConsent` is reached: the two checks overlap, and only the pure function is pinned by the fixture |
+| Nothing before `on` is stored | an `on` interval opened at `-Infinity` (`new Date(-8.64e15)`) | 14 + 0, including rows 10b, h1, h1b, h15, reverse 2, h2, forget-on |
+| A token closed twice ends at the earliest close | the latest close | 2 + 0 |
+| The key goes before the interval | the interval appended before the key | 1 + 0: "writes the key before the intervalโฆ" |
+| Ingest ends an interval its key no longer names | the live key is not compared with the interval's token | 15 + 0, including row 9 |
+| An interval that is still named stays open | every observed interval closed | 8 + 0 |
+| The prompt gate observes the key before it asks if a person is present | presence asked first | 2 + 1: rows h15 `off` and `--unset`; "a clone whose key was turned off by hand has its interval closed, now, even with nobody present" |
+| The end of an interval is excluded | `at <= to` | 14 + 0 |
+| The start of an interval is included | `from < at` | 7 + 0 |
+| A clone is told from another at its path by its identity | identity ignored, `cloneKey` is the path | 20 + 0 |
+| An unreadable git config refuses | the check removed | 2 + 0 |
+| A clone found gone ends its interval | a gone clone counted as still naming the token | 2 + 0 |
+| `on` ends the stale intervals of its clone | the close removed | 1 + 0 |
+| `task` applies the same test as the hooks | the interval check removed in `ConsentedRepositories` | 1 + 0 |
+| `off` asks the log only for a clone it can identify | `if (located.clone !== null)` always true | 1 + 0 |
+| A clone that never asked, with a damaged log, is "no consent", not "unreadable" | the early `absent` refusal removed in `ConsentedRepositories` | 1 + 0 |
+
+Mutation score of the `telemetry` scope at the head of the round: 96.2 (floor 96). Stryker survivors in the files of this round, none believed to be a gap: `consent-history.ts` `sameKeys` with `some` for `every` (the parse that follows rejects the line either way), `directory-resolver.ts` filter of the current owner (the owner list is the same set), `telemetry-on-use-case.ts` `held !== null` and the `value` check of the re-read key (a null token names no interval; the unreadable case was refused before), `consented-repositories.ts` `status: "open"` text and the `value` check (nothing reads them but the declare use cases, which only test for a refusal).
+
+### Residual limits recorded in the contract
+
+A manual change of the key between two observations (row h15b), the clock (a call is judged by its transcript timestamp), a worktree made and removed between two ingests, no birth time, a moved clone, a reused process id, and `AIDD_TELEMETRY=0` stopping the hooks too. See `usage-contract.md` ยง Consent, Residual limits.
+
+### Deviations and decisions of the round
+
+- `ownerAt` is kept, with its "else the first" fallback: with intervals it grants nothing on its own (the owner's interval must cover the call, and none opens before an `on`), but a clone with no birth time is known only from the day ingest first saw a directory in it, and a call from a subdirectory made before that day is still its call.
+- `rememberOwnRoot` is kept (renamed `remember-own-root.ts`): it records a root seen alive so `forget`'s `unlocated` count is right for a clone where `on` ran and no session did. It is not a consent flag.
+- `repository-consent.ts` (a one-line wrapper) is deleted; `resetPositions` is removed from the ledger port and adapter.
+- `aidd telemetry task` (and `show`) now require the same test as the hooks (key names an interval open for the clone); the brief named the hooks only.
+- The hook does nothing under `AIDD_TELEMETRY=0`, closes included: a residual limit.
+- A hook closes all open intervals of its clone's real path whose token the key does not name, including a stale one next to a named one.
+
+## Fix round 5
+
+Each mutation was applied by script (exact anchor, count 1) and run against `npx vitest run tests/contexts/telemetry tests/presentation` (CLI, 1623 tests) and `node --test scripts/__tests__/aidd-telemetry-hooks.test.js` (hooks, 61 tests). The file was restored from a copy after each. Counts are `CLI failed + hook failed`.
+
+| Fix | Mutation | Red |
+| --- | --- | --- |
+| 1 `on` refuses a damaged log | the guard `if (log.damaged) return "damaged"` made dead (`&& false`) | 2 + 0: rows "damaged on" and "damaged recovery" |
+| 1 `off` refuses a damaged log | the same guard in the `off` use case | 1 + 0: row "damaged off" |
+| 2 the hook compares the full identity | `sameIdentity(...) \|\| true` in `decideConsent` | 1 + 2: row "copy at the path", and the fixture cases for another inode, device and birth time |
+| 3 an unstattable clone never aborts | `readCloneIdentity` rethrows instead of `unidentified` | 3 + 0: the reader test, the locator test, row "unstattable" |
+| 4 a clone out of reach stays open | a clone with nothing at its path is closed again | 2 + 0: row "unmounted" and the ingest unit test |
+| 5 a broken `.git/config` is unreadable | the locator answers `outside-repository` again | 2 + 0: the locator test and row "broken config" |
+| 8 the journey no longer backdates consent | `on` opens its interval one hour late, built and run on `tests/e2e/telemetry-journey.e2e.test.ts` | 1 failed of 3: "total: expected 1 to be 5" |
+
+Fixes 6 (a comment) and 7 (a removed export) change no behaviour, so no test can go red for them. For 7, `git grep -n consentOf -- plugins scripts` finds nothing, and the hook test now uses `tokenOfKey`.
+
+Mutation score of the `telemetry` scope after the round: 96.2 (floor 96).
+
+### Deviations and decisions of the round
+
+- Recovery from a damaged log is `forget --yes` then `on`, by `on` and `off` refusing and by the advice; a hand edit that removes the damaged line is outside the contract and, as the review's script A2 does it (and then re-reads the transcripts), still stores the off window. The tool no longer suggests it. A latch that outlives a hand edit would be a design change and was not made.
+- A new `consent-closed` reason joins `coverage.not_stored`, and `coverage.consent_log_damaged` is added to the version 1 envelope.
+- The locator answers a new `unreadable` status for a directory it cannot look at and for a `.git` git cannot read; `outside-repo` is left for what is not a repository.
+
+## Fix round 6
+
+Each mutation was applied by script (exact anchor, count 1), run against the named test file, and the file restored from its bytes and compared equal. Counts are the failures observed.
+
+| Fix | Mutation | Red |
+| --- | --- | --- |
+| 1 smoke order | not mutated: the run before the fix is the proof. `telemetry off` after `forget --yes` printed "Measurement was not on for this clone." and the step failed ("PASS: 128 FAIL: 1" in the review). After the reorder, "PASS: 130 FAIL: 0" | n/a |
+| 2 the CLI parser ignores an unterminated last line | `terminated.pop();` removed in `parseConsentRecords` | 2 in the fixture test (`torn-last-line-is-not-yet-written`, `whole-event-without-its-newline-is-not-yet-written`), 1 in the row "torn tail" |
+| 2 the hook parser ignores it | `terminated.pop();` removed in `parseConsentLog` | 1 in the hook test "the consent log follows the fixture" (it loops the cases) |
+| 2 the CLI writer cuts the torn tail before appending | `await this.dropUnterminatedTail();` removed | 1 in the adapter test, 1 in the row "torn tail" |
+| 2 the hook writer cuts it | `{ dropTornTail: true }` turned `false` | 1 in the hook test "a torn last line leaves the clone measuredโฆ" |
+| 3 only a hook declaration waits less | `lockWaitFor` never answers a wait | 1 in "waits for the bindings lock less than the hook doesโฆ" |
+| 3 the snapshot waits less too | `lockWaitFor(declaration.by)` dropped in `BranchDeclarations.declare` | 1, same test (waits `[8000, undefined]`) |
+| 3 the session line waits less | the wait dropped in `DeclareTaskUseCase.execute` | 1, same test |
+| 3 the lock honours a wait | the `waitMs` override dropped in `DirectoryLock.exclusively` | 1 in "gives up after the wait a caller asks forโฆ" |
+| 3 the adapter passes it on | `wait?.waitMs` replaced by nothing in `BindingsLockAdapter` | 1, same test |
+| 5 `no-consent` wording | the display text put back to "has not opted in" | 1 in "lists the calls it did not store, by reason" |
+
+Fixes 4 and 6 are documents; no test can go red for them.
+
+### Deviations and decisions of the round
+
+- The writers' newline guard (`appendRecord`, `PrivateStorage.append`) was not enough for fix 2: it ends a torn tail with a newline, which turns it into a terminated line that is not an event, so the next `on`, `off` or hook close would have damaged the log. The test failed that way before the change (hook: `Unterminated string in JSON` reading the log back). The two consent writers now cut the unterminated tail before appending (`ftruncate` in the hook, a `replace` in the CLI). Every other file still gets the guard. The cut is not atomic with a concurrent append: a line another process appends between the read and the cut can be lost, a window of a few milliseconds that only opens on a log already torn.
+- A last line that would parse but has no newline is ignored too: a write is one line and its newline, so one without the other is not finished.
+- The hook-intercept wait is 8 s, not 10 s, because a declaration takes the bindings lock twice (the session line, then the branch snapshot): at most 16 s of waiting under the hook's 20 s.
+
+## After the first CI runs
+
+### A birth equal to the change time is a birth
+
+The first rule took a birth equal to the change time as no birth, in milliseconds, then in nanoseconds. Both were wrong, and the row "copy at the path" kept failing on Linux now and then, with `expected [ 'a1', 'b1' ] to deeply equal [ 'b1' ]`. Measured in Docker (`node:22-bookworm`, aarch64, kernel 7.0, OrbStack, overlay on btrfs):
+
+- **Cause, observed.** Two failing runs, instrumented after the ingest: the copy at `a/.git` was stored in `roots.json` with `birthtimeMs: 0`, and its stat had `birthtimeNs` equal to `ctimeNs` (`1791709014192859969` both). With no birth, the copy answers only from ingest's first sighting, so the call made before it went to the original clone, whose consent was open.
+- **Rate.** `cpSync` of a fresh git dir, 2000 times: birth `0` never, birth equal to the change time to the nanosecond 1529 times, in the same millisecond but not the same nanosecond 460 times. `statx` works there, and the file clock is coarse: a directory made and filled within one tick has both times equal.
+- **The nanosecond rule did not fix it.** The row failed 3 times in 150 under the millisecond rule, 5 times in 300 and 8 times in 300 under the nanosecond rule.
+
+A positive birth time is now kept as it is, by the CLI and the hook alike, and the change time is not read.
+
+| Rule | Mutation | Red |
+| --- | --- | --- |
+| a birth equal to the change time is kept, CLI | `\|\| stat.birthtimeNs === stat.ctimeNs` put back in `identityFromStat` | 2 in the unit test (the equal case, and the identity of a stat carrying no ns fields), 1 in the fixture test (`linux-birth-equal-to-change-time-is-a-birth`) |
+| the same, hook | the same in `consent.cjs` | 1 in the hook test "a directory's identity follows the fixture" |
+| an identity does not move when an entry is added | the same, CLI, run on Linux | "keeps its identity once an entry is added", 5 runs out of 5. It cannot fail on macOS, where APFS never had the two times equal over 500 directories |
+| the row "copy at the path", overlay on btrfs, root | the same, both sides | 8 runs failed out of 300. With the rule removed: 0 out of 400 |
+| the same, on an ext4 loop device, as the unprivileged `node` user | the same, both sides | 81 runs failed out of 200. With the rule removed: 0 out of 400 |
+
+On that ext4 device, as `node`, the whole consent table and the reader test passed 50 runs out of 50 with the rule removed. A fresh directory there had its birth equal to its change time; after an entry was added 50 ms later, the change time had moved and the birth had not.
+
+### Where Linux refuses `statx`
+
+Simulated on the same ext4 device: `strace -f -e inject=statx:error=ENOSYS` makes every `statx` fail as on a kernel before 4.11, and libuv falls back to `stat`. A fresh directory then had its birth equal to its change time, and after an entry was added both had moved together. The consent table, its assertions made soft so that each row reports what it finally stored, 36 rows, run as root:
+
+| Rule | Rows as expected | Stored less | Stored more |
+| --- | --- | --- | --- |
+| a birth equal to the change time is kept (this change), 3 runs | 1 | 34 | 1, every run: "on, off, the clone moved, on" stored `a2`, made after `off`, and not `a3` |
+| a birth equal to the change time is no birth (the rule before), 2 runs | 33 | 0 | 3, every run: "unmounted, made again" and "copy at the path", the residual where there is no birth time, and "unstattable", which `chmod` cannot shut to root |
+
+With the birth taken from the change time, the identity moves at each entry added to the git dir. Most rows then store less than they should. The one that stores more does so because `off` closed only the interval of the identity it read, which was no longer the one `on` opened.
+
+`off` now also closes the open interval its key names when that interval was opened at the same path. A copy elsewhere carries the token but not the path, and is left alone. The same simulation, as the unprivileged `node` user:
+
+| Rule | Rows as expected | Stored less | Stored more |
+| --- | --- | --- | --- |
+| `off` closes by identity, and by the key's token at its path, 3 runs | 1 | 35 | 0 |
+| the same, `statx` allowed, 2 runs | 36 | 0 | 0 |
+
+| Rule | Mutation | Red |
+| --- | --- | --- |
+| `off` closes the interval its key names at its path | the token clause removed | 1, "closes the interval its key names at its path, though the identity read there moved since `on`" |
+| only at its path | the path condition removed | 1, "leaves the interval of the clone a copy was made from, its key naming it at another path" |
+
+A residual limit of the usage contract: the clone stops being measured, and it fails closed.
+
+### The other guards of these commits
+
+| Guard | Mutation | Red |
+| --- | --- | --- |
+| a directory is not read as a transcript | `if (!stats.isFile()) throw` removed | 1, "fails on a transcript it cannot read". The test now asks for `/is not a file/`: before, it passed on POSIX without the guard, because reading a directory throws `EISDIR` there anyway |
+| the husky hook is named with `/` | not mutated: the path is the same on POSIX. CI run 38088839814, before the fix, failed 2 tests on Windows with `".husky\\prepare-commit-msg"` | Windows only |
+
+### The session id join, probed against the real tool
+
+`scripts/probe-identifier-join.cjs` runs the installed `claude -p` against a dead API, with a fake key and an empty home, and judges what one `SessionStart` hook and the transcript left, through the shipped `isClaudePayload` and `personIsPresent`. On 2.1.295 it exits 0 after its 90 s bound: `the join holds`. The CI job `cli / Identifier join (Claude Code)` runs it again. Its first CI run, on Ubuntu with 2.1.296, failed a first version that required `CLAUDE_PID` to be the hook's parent: `CLAUDE_PID 2565 is not the hook's parent 2588`. A shell stands between Claude and its hooks there, so the probe now requires `CLAUDE_PID` to be set, the variable the hook reads first, and the hook's comment no longer claims its parent is Claude.
+
+| Check | Mutation | Red |
+| --- | --- | --- |
+| a hook fired before the API call | the check made `false` | 2, "is a change of the tool when no hook fired before the API call" and "exits 1 when the tool fires no hook" |
+| the Claude-only guard accepts Claude's payload | the same | 1, "is a change when the hook's session variable is not its payload's id" |
+| the transcript is under the config dir's `projects` | the same | 1, "is a change when the transcript is not under the config dir's projects" |
+| a transcript was written | the same | 1, "is a change when no transcript was written" |
+| every transcript line names the hook's session | the same | 2, "is a change when a transcript line names another session" and "is a change when no transcript line names a session" |
+| at least one line names it | `ids.length === 0` removed | 1, "is a change when no transcript line names a session" |
+| `CLAUDE_PID` is set, so the hook does not fall back to its parent pid | the same | 1, "is a change when CLAUDE_PID is gone, leaving the hook's parent pid to name Claude" |
+| a headless run is not a person present | the same | 1, "is a change when a headless run reads as a person at the keyboard" |
+
+### Presence, observed on each surface
+
+Observed with the maintainer on 2026-10-12, recorded in [target.md](target.md): the terminal (`cli`) and the desktop app (`claude-desktop`) set `ATTENDED=1` and show a block's reason; the VS Code extension (`claude-vscode`) sets no `ATTENDED` and shows neither a reason nor a `systemMessage`. The rule was "any entrypoint not starting with `sdk`", which would have blocked a surface never seen; it is now the two surfaces seen to show the reason.
+
+| Rule | Mutation | Red |
+| --- | --- | --- |
+| the desktop app asks | `claude-desktop` removed from the set | row "the desktop app" of "presence: only ATTENDED=1 from a surface seen to show a block asks" |
+| a surface never seen does not | the set replaced by the previous "not `sdk`" rule | rows "attended, from a surface never seen" and "entrypoint empty" |
+| `ATTENDED=1` is required | the `ATTENDED` condition removed | rows "unattended", "attended variable absent" and "attended is true, not 1", and the test "presence: a typed declaration is not run for nobody" |
+| the VS Code extension is not asked | `claude-vscode` added to the set and the `ATTENDED` condition removed | row "the VS Code extension", with the three rows above. It holds by two independent conditions, so no single mutation reaches it: adding `claude-vscode` alone leaves it green, since the extension sets no `ATTENDED` |
+
+The matrix judges every row before it fails, so a mutation names all the rows it turns red.
+
+### Transcripts no consent can cover are not read
+
+A first ingest on a real 6.2 GB Claude history never finished in 22 minutes. With transcripts last written more than a day before the first interval left unread: 246 s, then 1.1 s. On a real session in a sandboxed telemetry directory, the stored calls match an independent count of the same transcripts: 5 calls, 217,337 tokens, per session alike. The Docker consent table is unchanged: 36 of 36 with `statx`, 2 runs; with it refused, 0 rows storing more, 3 runs.
+
+| Rule | Mutation | Red |
+| --- | --- | --- |
+| a day of margin for the file clock | the margin removed | "reads a transcript whose file clock lags the call clock, within a day" |
+| skipping stores what reading everything stores | the margin added instead of taken | the above, and "stores exactly what reading every transcript stores" |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md
new file mode 100644
index 000000000..cb37504be
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md
@@ -0,0 +1,68 @@
+# What the previous telemetry left on disk
+
+Recorded from the tree before phase 1 deleted it (`cli/src/...`, line numbers are of that tree, recoverable from the parent of the phase-1 commit). Input for phase 8 (`on` cleans, `forget` removes).
+
+## Sink directory (day files)
+
+Source: `contexts/telemetry/infrastructure/telemetry-sink-adapter.ts`.
+
+- Resolution (`:101-104`): `named = process.env.AIDD_TELEMETRY_DIR`; `legacy = userConfigDir ?? process.env.AIDD_USER_CONFIG_DIR`; `rootDir = named ?? join(legacy ?? defaultConfigDir(), "telemetry")`.
+- `defaultConfigDir()` (`:55-64`): not win32 -> `join(resolveHomeDir(), ".config", "aidd")` (`legacyConfigDir`, `:44-46`); win32 -> legacy dir when `/telemetry/*.jsonl` exists, else `join(process.env.APPDATA, "aidd")`, else legacy dir.
+- Files: one per UTC day, `YYYY-MM-DD.jsonl` (`DAY_FILE_EXTENSION = ".jsonl"` `:21`, `dayKey` = `at.toISOString().slice(0, 10)` `:27-29`), appended. Modes `0o600` file, `0o700` dir (`:22-23`) unless the location was user-named.
+- `AIDD_USER_CONFIG_DIR` also relocates `auth.json`, `marketplaces.json`, `references.json` (`user-manifest-repository-adapter.ts:14`).
+
+## Identity
+
+Source: `contexts/telemetry/infrastructure/person-identity-adapter.ts:59`, `kernel/reading/home-dir.ts:22-27`.
+
+- File: `join(resolveAiddConfigDir(), "identity.json")`.
+- `resolveAiddConfigDir()`: win32 with `APPDATA` -> `%APPDATA%\aidd`; otherwise `~/.config/aidd`. Never `AIDD_USER_CONFIG_DIR`.
+- Shape (`person-identity-adapter.ts:41-48`): `{ person_id, origin: "minted" | "adopted", display_name?, also_me?: string[] }`, pretty JSON + newline.
+
+## Commit trailer delegate
+
+Source: `contexts/telemetry/domain/formats/commit-session-trailer.ts`.
+
+- Trailer token (`:11`): `AIDD-Session-Id`, written as `--trailer "AIDD-Session-Id=$session_id"`; session id = `${CODEX_THREAD_ID:-${CLAUDE_CODE_SESSION_ID:-}}`.
+- Delegate file name (`:15`): `aidd-session-trailer.sh`; header `#!/bin/sh` (`:21`); second line `# Installed by \`aidd telemetry on\`, removed by \`aidd telemetry off\`.` (`:83`).
+- Delegate directory (`runtime/git/git-adapter.ts:242-249`): no manager -> `git rev-parse --git-path hooks`; under lefthook/husky -> `/hooks`.
+- Hook line appended to `prepare-commit-msg` (`:26-28`): `sh "" "$@"`. The hook file is `prepare-commit-msg` (`git-adapter.ts:24`), made executable.
+- Lefthook snippet (`:42-49`), added to `lefthook.yml` under `prepare-commit-msg:`:
+
+```yaml
+prepare-commit-msg:
+ commands:
+ aidd-session-trailer:
+ run: |
+ delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh"
+ if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi
+```
+
+- Husky line (`:53-56`), in `.husky/prepare-commit-msg`:
+
+```sh
+delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh"
+[ -f "$delegate" ] && sh "$delegate" "$@"
+```
+
+- Manager detection (`telemetry-setup.ts:176-190`): `lefthook.yml`, `lefthook.yaml`, `.lefthook.yml`, `.lefthook.yaml` -> lefthook (wins ties); `.husky` -> husky. Under a manager the CLI never edited the manager's config; it printed the snippet.
+
+## Run journal
+
+Source: `kernel/paths.ts:14-31`.
+
+- Directory: `aidd_docs/runs/` at the repository root above the project (`DOCS_DIR = "aidd_docs"`, `RUNS_SUBDIR = "runs"`); `AIDD_RUNS_DIR` overrides it outright.
+- `RUNS_ENTRY` (`:22`) = `aidd_docs/runs/`, the `.gitignore` line `telemetry on` added (via `GitignoreUseCase`) and `manifest-gitignore-entries.ts:13` added to the managed set `[".aidd/cache/", RUNS_ENTRY, ...]`.
+- Unrecognised-file quarantine name (`telemetry-evidence-adapter.ts:34`): `_unrecognised.jsonl`, inside the runs directory.
+
+## `.aidd/config.json`
+
+Source: `contexts/telemetry/domain/telemetry-switch.ts`.
+
+- Key `telemetry` (`:11-58`): `{ "enabled": boolean, "endpoint"?: string }`. `endpoint` was written by a since-removed `telemetry endpoint` command; `on` and `off` preserved it verbatim, nothing read it.
+- Refusal variable (`:23`): `AIDD_TELEMETRY`; only the literal `"0"` refuses.
+- The new version treats a bare `enabled: true` (no `version: 2`) as not opted in.
+
+## Claude Code settings export leftovers
+
+Source: `contexts/telemetry/domain/telemetry-export-leftover.ts:7-15`. `env` keys the removed `telemetry endpoint` wrote into a Claude Code settings file: `CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_METRICS_EXPORTER`, `OTEL_LOGS_EXPORTER`, `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_RESOURCE_ATTRIBUTES`.
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/check.py b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/check.py
new file mode 100644
index 000000000..ab3534f2b
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/check.py
@@ -0,0 +1,82 @@
+"""Prototype: two tool readers -> one usage-record contract. Asserts on synthetic fixtures."""
+import json, glob, os, sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+FX = os.path.join(HERE, "fixtures")
+
+def rec(tool, key, session, agent, at, model, inp, out, cr, cw, cw1h, reasoning, cwd, branch, version):
+ return dict(tool=tool, key=key, session_id=session, agent=agent, at=at, model=model,
+ input=inp, output=out, cache_read=cr, cache_write=cw, cache_write_1h=cw1h,
+ reasoning=reasoning, cwd=cwd, git_branch=branch, tool_version=version)
+
+def claude(files):
+ best = {}
+ for f in files:
+ for raw in open(f):
+ r = json.loads(raw); m = r.get("message") or {}; u = m.get("usage")
+ if r.get("type") != "assistant" or not isinstance(u, dict) or m.get("model") == "":
+ continue
+ key = f"{m['id']}:{r['requestId']}" if r.get("requestId") else f"{m['id']}:{r['sessionId']}:{r['timestamp']}"
+ total = sum(u.get(k) or 0 for k in ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"))
+ if key not in best or total > best[key][0]:
+ best[key] = (total, r)
+ out = []
+ for key, (_, r) in best.items():
+ m = r["message"]; u = m["usage"]; its = u.get("iterations") or []
+ msgs = [i for i in its if i.get("type") == "message"] or [u]
+ cw = sum(i.get("cache_creation_input_tokens") or 0 for i in msgs)
+ splits = [i.get("cache_creation") for i in msgs]
+ cw1h = sum(s.get("ephemeral_1h_input_tokens", 0) for s in splits) if all(isinstance(s, dict) for s in splits) else None
+ agent = "subagent" if r.get("isSidechain") or r.get("agentId") else "main"
+ base = (r["sessionId"], None, r["timestamp"])
+ out.append(rec("claude-code", key, r["sessionId"], agent, r["timestamp"], m["model"],
+ u.get("input_tokens"), u.get("output_tokens"), u.get("cache_read_input_tokens"),
+ cw, cw1h, None, r.get("cwd"), r.get("gitBranch"), r.get("version")))
+ for n, a in enumerate(i for i in its if i.get("type") == "advisor_message"):
+ out.append(rec("claude-code", f"{key}#advisor{n}", r["sessionId"], "advisor", r["timestamp"], a.get("model"),
+ a.get("input_tokens"), a.get("output_tokens"), a.get("cache_read_input_tokens"),
+ a.get("cache_creation_input_tokens"), None, None, r.get("cwd"), r.get("gitBranch"), r.get("version")))
+ return out
+
+def codex(path):
+ out = []; session = version = cwd = branch = model = None; last_total = None
+ for raw in open(path):
+ r = json.loads(raw); p = r.get("payload") or {}
+ if r["type"] == "session_meta":
+ session, version, cwd = p["id"], p.get("cli_version"), p.get("cwd")
+ branch = (p.get("git") or {}).get("branch")
+ elif r["type"] == "turn_context":
+ model, cwd = p.get("model"), p.get("cwd", cwd)
+ elif p.get("type") == "token_count" and p.get("info"):
+ total = p["info"]["total_token_usage"]["total_tokens"]
+ if total == last_total: # verbatim re-emission, not a new call
+ continue
+ last_total = total; l = p["info"]["last_token_usage"]
+ out.append(rec("codex", f"{session}:{total}", session, "main", r["timestamp"], model,
+ l["input_tokens"] - l["cached_input_tokens"], l["output_tokens"], l["cached_input_tokens"],
+ l.get("cache_write_input_tokens"), None, l.get("reasoning_output_tokens"), cwd, branch, version))
+ return out
+
+c = claude(sorted(glob.glob(FX + "/claude-*.jsonl")) + glob.glob(FX + "/subagents/*.jsonl"))
+x = codex(FX + "/codex-rollout.jsonl")
+by = {r["key"]: r for r in c + x}
+expect = {
+ "msg_A:req_A": dict(agent="main", input=3, output=250, cache_read=1000, cache_write=200, cache_write_1h=200),
+ "msg_B:req_B": dict(agent="main", input=4, output=300, cache_read=5000, cache_write=2283, cache_write_1h=2283),
+ "msg_B:req_B#advisor0": dict(agent="advisor", model="claude-fable-5-1", input=90000, output=700, cache_write_1h=None),
+ "msg_C:req_C": dict(agent="subagent", model="claude-sonnet-5-5", input=5, cache_write=60, cache_write_1h=0),
+ "cx-1:1050": dict(input=200, cache_read=800, output=50, reasoning=20, cache_write_1h=None),
+ "cx-1:2630": dict(input=500, cache_read=1000, output=80, reasoning=20, model="gpt-5.5-codex"),
+}
+fail = 0
+keys = [r["key"] for r in c + x]
+if len(keys) != len(set(keys)):
+ print("DUPLICATE KEYS"); fail += 1
+if set(by) != set(expect):
+ print("KEYS", sorted(set(by) ^ set(expect))); fail += 1
+for k, e in expect.items():
+ for f, v in e.items():
+ if by.get(k, {}).get(f, "MISSING") != v:
+ print(f"FAIL {k}.{f} = {by.get(k, {}).get(f, 'MISSING')} expected {v}"); fail += 1
+print(f"{len(c)} claude + {len(x)} codex records, {fail} failures")
+sys.exit(1 if fail else 0)
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-main.jsonl b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-main.jsonl
new file mode 100644
index 000000000..69c2903d4
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-main.jsonl
@@ -0,0 +1,4 @@
+{"type":"assistant","sessionId":"s-1","requestId":"req_A","timestamp":"2026-10-07T10:00:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":10,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}}
+{"type":"assistant","sessionId":"s-1","requestId":"req_A","timestamp":"2026-10-07T10:00:01.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":250,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}}
+{"type":"assistant","sessionId":"s-1","requestId":"req_B","timestamp":"2026-10-07T10:01:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a/src","gitBranch":"feat/a","message":{"id":"msg_B","model":"claude-opus-5-5","usage":{"input_tokens":4,"output_tokens":300,"cache_read_input_tokens":5000,"cache_creation_input_tokens":2283,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1436},"iterations":[{"type":"message","input_tokens":2,"output_tokens":100,"cache_read_input_tokens":2500,"cache_creation_input_tokens":1436,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1436}},{"type":"advisor_message","model":"claude-fable-5-1","input_tokens":90000,"output_tokens":700,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},{"type":"message","input_tokens":2,"output_tokens":200,"cache_read_input_tokens":2500,"cache_creation_input_tokens":847,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":847}}]}}}
+{"type":"assistant","sessionId":"s-1","requestId":"req_S","timestamp":"2026-10-07T10:02:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_S","model":"","usage":{"input_tokens":0,"output_tokens":0}}}
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-resumed-copy.jsonl b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-resumed-copy.jsonl
new file mode 100644
index 000000000..b13b25955
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-resumed-copy.jsonl
@@ -0,0 +1 @@
+{"type":"assistant","sessionId":"s-2","requestId":"req_A","timestamp":"2026-10-07T10:00:01.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":10,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}}
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/codex-rollout.jsonl b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/codex-rollout.jsonl
new file mode 100644
index 000000000..3e93a17d6
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/codex-rollout.jsonl
@@ -0,0 +1,6 @@
+{"type":"session_meta","timestamp":"2026-10-07T09:00:00.000Z","payload":{"id":"cx-1","cwd":"/work/repo-wt-b","cli_version":"0.150.0","git":{"branch":"fix/b"}}}
+{"type":"turn_context","timestamp":"2026-10-07T09:00:01.000Z","payload":{"turn_id":"t-1","model":"gpt-5.5-codex","cwd":"/work/repo-wt-b"}}
+{"type":"event_msg","timestamp":"2026-10-07T09:00:05.000Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":800,"cache_write_input_tokens":0,"output_tokens":50,"reasoning_output_tokens":20,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":800,"cache_write_input_tokens":0,"output_tokens":50,"reasoning_output_tokens":20,"total_tokens":1050}}}}
+{"type":"event_msg","timestamp":"2026-10-07T09:00:09.000Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2500,"cached_input_tokens":1800,"cache_write_input_tokens":0,"output_tokens":130,"reasoning_output_tokens":40,"total_tokens":2630},"last_token_usage":{"input_tokens":1500,"cached_input_tokens":1000,"cache_write_input_tokens":0,"output_tokens":80,"reasoning_output_tokens":20,"total_tokens":1580}}}}
+{"type":"event_msg","timestamp":"2026-10-07T09:00:09.500Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2500,"cached_input_tokens":1800,"cache_write_input_tokens":0,"output_tokens":130,"reasoning_output_tokens":40,"total_tokens":2630},"last_token_usage":{"input_tokens":1500,"cached_input_tokens":1000,"cache_write_input_tokens":0,"output_tokens":80,"reasoning_output_tokens":20,"total_tokens":1580}}}}
+{"type":"event_msg","timestamp":"2026-10-07T09:00:10.000Z","payload":{"type":"token_count","info":null}}
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/subagents/agent-x.jsonl b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/subagents/agent-x.jsonl
new file mode 100644
index 000000000..86bd05984
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/subagents/agent-x.jsonl
@@ -0,0 +1 @@
+{"type":"assistant","sessionId":"s-1","agentId":"x","isSidechain":true,"requestId":"req_C","timestamp":"2026-10-07T10:03:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_C","model":"claude-sonnet-5-5","usage":{"input_tokens":5,"output_tokens":40,"cache_read_input_tokens":300,"cache_creation_input_tokens":60,"cache_creation":{"ephemeral_5m_input_tokens":60,"ephemeral_1h_input_tokens":0}}}}
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/real-history-oracle.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/real-history-oracle.md
new file mode 100644
index 000000000..f3f288b83
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/real-history-oracle.md
@@ -0,0 +1,51 @@
+# Real-history oracle
+
+One manual run, on 2026-10-09, comparing what the built CLI counts on a real Claude Code history
+with an independent count of the same files. Only counts are kept here: no session id, prompt,
+path or token volume of the person whose history it was.
+
+## Method
+
+1. **Projection.** Every `.jsonl` under the real Claude `projects/` directory was read, never
+ written. Each `assistant` line was copied into a sandbox `CLAUDE_CONFIG_DIR`, with its message
+ content emptied and its `cwd` rewritten to one sandbox git repository opted in with
+ `telemetry: {enabled: true, version: 2}`. Lines of other types were dropped. The counters, ids,
+ timestamps and `iterations` were kept as they were.
+2. **CLI.** The built `cli/dist/cli.js` ran `aidd telemetry report --axis day --json` from that
+ repository. `HOME`, `CLAUDE_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `AIDD_USER_CONFIG_DIR` and
+ `XDG_CONFIG_HOME` all pointed into the sandbox, and `CLAUDE_CODE_SESSION_ID` was unset.
+3. **Independent count.** The `claude()` reader of [`prototype/check.py`](./prototype/check.py)
+ (Python, written before the CLI) read the same files. Its records were grouped by the UTC day
+ of `at`, and each day's four counters and record count were compared with the CLI's row.
+
+> **Note, 2026-10-10.** Step 1 describes the opt-in of the day the run was recorded (`b2b732eb`,
+> 2026-10-09): `telemetry: {enabled: true, version: 2}` in `.aidd/config.json`. It does
+> not count at HEAD, and the backfill is gone: consent is `aidd telemetry on` in the clone, and
+> a call is stored only if its transcript time falls inside the interval that `on` opened. The counting code has not changed what it counts since the recording: `git diff --stat b2b732eb HEAD`
+> over `domain/formats`, `usage-fold.ts`, `usage-record.ts` and the transcript source adapter
+> shows only a guard that refuses a directory as a transcript (`c4927e5c`), which a file never
+> meets. To repeat the method at HEAD, run `aidd telemetry on --yes` in the sandbox repository
+> before the transcripts are produced, and date them after it. The comparison then checks
+> counting only, as before. The result below is not rewritten.
+
+The rewrite of `cwd` means this run checks counting, not attribution. Attribution is covered by
+the e2e journey on synthetic transcripts.
+
+## Result
+
+| Measure | Value |
+| ------- | ----- |
+| Transcript files read | 2,746 |
+| Records, CLI and independent count | 262,328 each |
+| Days compared | 68 |
+| Days differing on any counter or on the record count | 0 |
+| Records with an unknown counter | 0 |
+| Unrecognised shapes, lines not stored | 0, 0 |
+
+Both sides sum cache writes over `iterations[type=message]`. That sum is the rule the previous
+reference tool lacked, which is why it under-counted cache writes. Agreement here therefore proves
+that two implementations of the contract agree. It does not prove that the contract matches
+billing.
+
+`oldest_transcript_at` in that run reflects when the sandbox copy was made, not the original
+history, so it is not reported.
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md
new file mode 100644
index 000000000..14545d35c
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md
@@ -0,0 +1,106 @@
+# Telemetry target, release 1 (v3, after shadow areas and four challenges)
+
+> Research record. Where it differs from [`../spec.md`](../spec.md), the spec wins: release 1
+> sends nothing (point 8 is out), the previous telemetry is removed rather than reused, and the
+> command's final name is set by the plan.
+
+Goal: Claude Code only. Deterministic tokens per person, session, model, day, repository;
+tokens per task and ticket. Push to Gouvernail. Ship fast and clean.
+
+## Release 1
+
+1. **Reader.** `/projects/**`, recursive (subagents,
+ workflows, `.jsonl.superseded-*` set-asides). Byte offset per file; a file that shrank or
+ changed identity is re-read whole; an unterminated last line waits. Key
+ `message.id:requestId` (requestless: `message.id:sessionId:timestamp`), global fold: max
+ total, tie โ earliest `at`, then lowest `session_id`. Advisor iterations are records;
+ cache writes summed over message iterations; `` dropped. Root resolved per
+ line (subagents run in their own worktrees).
+2. **Root and repository at ingest.** realpath + case-fold โ `git rev-parse` โ root;
+ repository id = hash of `host/owner/repo` from `origin`, else the root commit SHA.
+ cwd gone (16 % of lines on backfill) โ judged by the clone it was last seen in (told from a
+ clone made at the same path by its identity), else counted as `never-seen-alive`, not
+ `outside-repo`.
+ A call is stored only when its clone had an open consent interval at the call's time (the
+ `aidd.telemetry` key `2:` and the interval `on` wrote in `ledger/consents.jsonl`, closed
+ by `off`, by ingest or by a hook that sees the key change); nothing before `on` is read
+ back. `AIDD_TELEMETRY=0` โ not ingested at all.
+3. **Ledger** in its own subdirectory of the telemetry dir (the v1 sink prunes `*.jsonl`
+ by count there), relocated by `AIDD_TELEMETRY_DIR`; monthly partitions; newline guard;
+ lock with pid and age, stale lock cleared. `forget` covers it.
+4. **Ingest**: every `aidd telemetry` command; `SessionStart` async catch-up (also fires on
+ resume, clear, compact: idempotent). No ingest in `Stop` (hooks are skipped under
+ `--bare`, safe mode, managed-only hooks, and blocked in cloud sessions). Setup offers
+ `cleanupPeriodDays: 3650`, reports a managed override or a paused sweep. Reports state
+ coverage.
+5. **Binding: asked once per working branch, remembered, all inside the plugin.**
+ - Store: `git config branch..aiddTask` (+ ticket, created-at from reflog). Follows
+ renames, dies with the branch. The branch name is never parsed.
+ - Session override: `aidd telemetry task` run inside a session (`CLAUDE_CODE_SESSION_ID`,
+ Codex `CODEX_THREAD_ID`) binds that session; it beats the branch. Measured: a `!`
+ command sees `CLAUDE_CODE_SESSION_ID` and `ATTENDED=1`, and does not fire
+ `UserPromptSubmit`, so answering the block is never itself blocked. `/clear` starts a
+ new session id and the payload carries no previous id, but the process survives
+ (measured: `CLAUDE_PID` and hook parent identical across startup + 2 `/clear`).
+ `SessionStart` records `(CLAUDE_PID, process start time) โ session`; on
+ `source: clear` it copies the predecessor's binding to the new id, tagged
+ `carried: clear from `. `resume` uses the resumed session's own binding; `fork`
+ carries the predecessor's binding like `clear`; `startup` carries nothing. The machine's
+ boot time guards PID reuse (a pid reused within one boot is a residual limit, see
+ `usage-contract.md`).
+ - A carried binding is provisional: it is announced (`systemMessage`, measured visible:
+ "SessionStart:clear says: โฆ") with the command to change it, and the first explicit
+ `aidd telemetry task` in that session replaces it for the whole session, retroactively.
+ An explicit binding in a session that already had one applies from its timestamp.
+ The prompt is never read to guess the need.
+ - When to ask (pure code, `UserPromptSubmit`, every prompt, cheap): telemetry on for the
+ project AND `CLAUDE_CODE_SESSION_ATTENDED=1` AND branch is neither the default
+ (`origin/HEAD`) nor detached AND neither branch nor session is bound โ block, reason
+ shown to the human: `! aidd telemetry task [--ticket X]` or `--none`. Re-checked
+ each prompt, so switching to an unbound branch asks again. Blocking before the model
+ call means no token is spent unattributed.
+ - Unattended (`ATTENDED=0` with `ENTRYPOINT=sdk-cli` under `-p`, measured;
+ `CLAUDE_CODE_CHILD_SESSION=1` in both modes, so not a signal) or flag absent: never
+ block (a blocked `-p` exits 0 having done nothing, measured). Bound branch โ bound
+ automatically; unbound โ `no-binding`, re-attributed once the branch is bound.
+ - Subagent and workflow lines carry the parent `sessionId` โ inherit its binding.
+ - Skills that know the ticket call the command first, so the block never shows.
+ - No launcher, orchestrator, env var or branch convention is required or read.
+6. **Attribution** (one pure local function, decided: the command is the only source):
+ session declaration, else the session's carry, else the branch declaration in force at the
+ call, else
+ `unattributed` with reason `no-binding | declared-none`.
+7. **Report axes**: person (v1 opt-in `person_id`), session, model, day, repository, task,
+ ticket. Four counters always separate.
+8. **Push**: opted-in records, attributed locally; no cwd, root, or branch names. Upsert by
+ `(person, tool, key)`; a re-bind re-pushes affected keys.
+
+## Deferred (they caused most of the silent-misattribution findings)
+
+Root-scoped bindings and their adoption/`ambiguous` rule; cross-machine binding sync;
+"session seen" marker; ingest inside `Stop`; cloud sessions (need managed settings).
+
+## Verified (sandbox, Claude Code 2.1.295)
+
+- Hooks fire in interactive and `-p`; `ATTENDED` 1 vs 0; `!` carries the session id;
+ `/clear` = new id, no link in the payload.
+- Phase 6 probes (Claude Code 2.1.295, interactive, sandbox config dir, dummy key; ids and paths redacted):
+ - A hook `{"decision":"block","reason":"R"}` shows `UserPromptSubmit operation blocked by hook:R` followed by `Original prompt: `; with `hookSpecificOutput.suppressOriginalPrompt: true` the reason shows and the `Original prompt:` line is gone. The blocked text is put back in the input box, so the person can retype it.
+ - `/branch` fires `SessionStart` with `source: "fork"`. Payload keys: `session_id`, `transcript_path`, `cwd`, `prompt_id`, `hook_event_name`, `source`, `model`, `session_title` (` (Branch)`). No parent session id anywhere in payload or env. `CLAUDE_PID` and the hook's parent pid are unchanged from the original session, so the predecessor is the pid's previous session, as for `/clear`.
+ - On `/clear` and on `/branch`, `CLAUDE_CODE_SESSION_ID` in the hook equals the payload `session_id` (the Claude-only guard holds on both), and the next `UserPromptSubmit` carries the new id in both.
+ - `UserPromptSubmit` does not fire for `!` commands (measured earlier, re-checked).
+ - End to end with the shipped hooks: an unbound `feat/x` prompt is blocked with both forms named; `aidd telemetry task demo --ticket P-1` typed as a prompt reaches the CLI as `telemetry task demo --ticket P-1 --by hook-intercept` and the next prompt passes; `/clear` appends a carry from the same pid and shows `SessionStart:clear says: Task demo kept after /clear. Different work: aidd telemetry task [--ticket ]`; the async catch-up runs `telemetry ingest --quiet` once per session start.
+ - Observed with the maintainer, 2026-10-12 (phase 6 task 5), from a probe project whose hooks recorded the variables and blocked one prompt per session:
+ - Terminal: `ATTENDED=1`, `ENTRYPOINT=cli`. The block reason shows.
+ - Desktop app: `ATTENDED=1`, `ENTRYPOINT=claude-desktop`. The block reason shows ("Un hook a bloquรฉ votre invite" and the reason).
+ - VS Code extension: `ATTENDED` unset, `ENTRYPOINT=claude-vscode`. A blocked prompt vanishes: the reason reaches the transcript only, a `systemMessage` sent with a block is not even recorded, and one sent without a block is recorded but not shown. No hook can tell the person anything there.
+ - On all three, the payload's `session_id` equals `CLAUDE_CODE_SESSION_ID`, and `CLAUDE_PID` is set.
+- Codex `UserPromptSubmit` can block (docs: `decision: block` or exit 2). Its unattended
+ signal is not yet known: Codex stays out of release 1.
+- `CLAUDE_CODE_SESSION_ATTENDED` is undocumented: a contract test pins its two values, and
+ the block also requires `CLAUDE_CODE_ENTRYPOINT` to be `cli` or `claude-desktop`, the surfaces seen to show a block's reason (two independent signals must agree).
+
+## Oracles
+
+Fixture + mutation per rule (contract/check.py: 6/6 killed); 16 counting pitfalls; ccusage
+diff; OTel `api_request` by `request_id`; Analytics API daily totals.
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/usage-record-contract.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/usage-record-contract.md
new file mode 100644
index 000000000..184be68b6
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/usage-record-contract.md
@@ -0,0 +1,45 @@
+# Usage record contract (draft v0)
+
+One record per billed model call. Facts only: no cost, person, task, ticket or step.
+Those are derived downstream, once, for every tool.
+
+| Field | Type | Meaning |
+| --- | --- | --- |
+| `tool` | string | `claude-code`, `codex`, โฆ |
+| `tool_version` | string \| null | version that wrote the source line |
+| `key` | string | stable across re-reads, unique within `tool`; storage is idempotent on `(tool, key)` |
+| `session_id` | string | the tool's own session id |
+| `agent` | `main` \| `subagent` \| `advisor` | who made the call |
+| `at` | ISO-8601 UTC | when the call was recorded |
+| `model` | string \| null | model that billed this call |
+| `input` | int \| null | input tokens **excluding** any cache read or write |
+| `output` | int \| null | output tokens **including** reasoning |
+| `cache_read` | int \| null | input tokens served from cache |
+| `cache_write` | int \| null | input tokens written to cache, all TTLs |
+| `cache_write_1h` | int \| null | of which 1-hour TTL; `null` when the tool does not say |
+| `reasoning` | int \| null | of which reasoning, a subset of `output`; `null` when the tool does not say |
+| `cwd` | string \| null | raw working directory of that line, joined to a worktree downstream; local only |
+| `git_branch` | string \| null | raw, as the line says it; the reader does not interpret it. Attribution uses it only to find the branch's declaration snapshot, together with `repository_id` |
+
+Rules: an unknown value is `null`, never `0`. A shape the reader does not recognise is reported, never guessed.
+
+## Per-tool normalisation (proved on fixtures, each by a mutation)
+
+Claude Code
+- key `message.id:requestId`; requestless lines `message.id:sessionId:timestamp`; keep the row with the largest total (streaming snapshots, resumed copies).
+- drop ``.
+- `cache_write` and `cache_write_1h` summed over `iterations[type=message]`; the top-level `cache_creation` split reflects only the first iteration (measured, 86 requests on 2026-10-07; ccusage 20.0.26 under-counts by it).
+- each `iterations[type=advisor_message]` is its own record, `agent: advisor`, its own model; top-level usage excludes it.
+- `isSidechain` or `agentId` โ `subagent`.
+
+Codex
+- key `session_meta.id:total_token_usage.total_tokens` (monotonic: 3,765/3,765 on 50 real rollouts).
+- skip a `token_count` whose cumulative total did not move (verbatim re-emission, 3.5 % of events on 200 rollouts).
+- `input = last.input_tokens โ last.cached_input_tokens` (OpenAI input is inclusive of cache).
+- model from the preceding `turn_context`.
+
+## Open
+
+- Codex forks and sub-agents: `session_meta` carries `forked_from_id`, `parent_thread_id`, `subagent_history_start_ordinal`. Does a forked rollout replay parent `token_count` events? If so the key above double-counts across sessions.
+- Claude `cache_write_1h` across iterations when a later iteration writes 1h: fixture asserts the sum; real occurrence not yet measured.
+- `cwd` per line for Codex: `turn_context.cwd` assumed to follow directory changes; not measured.
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-1.md
new file mode 100644
index 000000000..0f5ea1c98
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-1.md
@@ -0,0 +1,93 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Remove the previous telemetry from the CLI
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+cli/
+โโโ src/
+โ โโโ cli.ts โ๏ธ no telemetry registration (re-added in phase 4)
+โ โโโ contexts/telemetry/ โ whole directory
+โ โโโ contexts/tools/domain/contracts.ts โ๏ธ drop telemetryLocalRead, telemetryJournalHost, telemetryTaskAttributable
+โ โโโ contexts/tools/domain/registry.ts โ๏ธ drop journalHostToAiToolId
+โ โโโ contexts/tools/domain/profiles/*/profile.ts โ๏ธ drop the three telemetry fields
+โ โโโ contexts/tools/domain/profiles/claude/claude-transcript-location.ts โ
+โ โโโ contexts/tools/domain/profiles/codex/codex-transcript-location.ts โ
+โ โโโ kernel/measurement.ts โ
+โ โโโ kernel/errors.ts โ๏ธ drop telemetry-only errors (keep any still thrown elsewhere)
+โ โโโ kernel/paths.ts โ๏ธ drop RUNS_ENTRY/resolvedRunsDir unless framework still needs them
+โ โโโ kernel/reading/home-dir.ts โ๏ธ drop resolveAiddConfigDir (only caller deleted)
+โ โโโ runtime/git/git-adapter.ts โ๏ธ drop the commit-trailer delegate methods
+โ โโโ runtime/wiring/telemetry.ts โ
+โ โโโ runtime/wiring/framework.ts โ๏ธ no TelemetryDeps
+โ โโโ runtime/wiring/installed-plugins-from-manifest.ts โ if telemetry was its only caller
+โ โโโ presentation/commands/telemetry.ts โ
+โ โโโ presentation/display/{telemetry-*,cost-report-*}.ts โ
+โโโ tests/ โ every test of the above; โ๏ธ architecture allow-lists
+โโโ biome.json โ๏ธ drop telemetry layer overrides
+โโโ mutation-scopes.json, package.json โ๏ธ drop the telemetry mutation scope and script
+โโโ scripts/check-bundle-size.mjs โ๏ธ untouched here: the budget is set once, in phase 9
+โโโ scripts/smoke-tools.sh โ๏ธ drop telemetry smoke steps
+โโโ tests/golden/snapshots/help/surface.json โ๏ธ regenerated, no telemetry
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Developer runs aidd --help] --> B[No telemetry command listed]
+ B --> C[Every other command behaves as before]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Build the CLI from the branch => binary built: 5: system
+ section Happy path
+ Run aidd --help => no telemetry entry, every other command listed as before: 5: cli
+ Run the full CLI suite => green with no telemetry test left: 5: cli
+ section Edge case - straggler
+ A file still imports a deleted module => typecheck and knip fail => the straggler is named: 1: cli
+```
+
+## Tasks to do
+
+### `1)` Delete the context and its wiring
+
+> Nothing under `contexts/telemetry` survives, and nothing imports it.
+
+1. Delete `cli/src/contexts/telemetry/`, `runtime/wiring/telemetry.ts`, `presentation/commands/telemetry.ts`, and the telemetry and cost-report display modules.
+2. Remove the registration from `cli.ts` and `TelemetryDeps` from `runtime/wiring/framework.ts`.
+3. Remove telemetry-only code living elsewhere: `kernel/measurement.ts`, the three profile fields and their contract, the transcript locations, `journalHostToAiToolId`, `resolveAiddConfigDir`, telemetry-only errors, the git adapter's trailer delegate methods.
+4. Keep `kernel/paths.ts` `AIDD_DIR`/`AIDD_CONFIG_FILENAME`; keep `RUNS_ENTRY` only if `framework` still reads it, otherwise delete it and its gitignore entry producer.
+
+### `2)` Delete the tests and update the guards
+
+> The suites and architecture guards describe the CLI that remains.
+
+1. Delete every telemetry test, fixture and in-memory port double listed by `grep -ril telemetry cli/tests`.
+2. Remove `telemetry` from `ALLOWED`, `PUBLIC_MODULES`, folder-size, orchestrator-deps, tool-addition-cost, catches-that-swallow and self-reentry baselines; drop the biome telemetry overrides.
+3. Remove the telemetry mutation scope and script; regenerate the help golden with `UPDATE_HELP_GOLDEN=1`.
+4. Drop telemetry steps from `scripts/smoke-tools.sh`. Leave the bundle budget alone.
+5. Before deleting, copy into phase 8's notes every location the previous version wrote: the sink dir resolution (`AIDD_TELEMETRY_DIR`, `AIDD_USER_CONFIG_DIR`, `%APPDATA%\aidd\telemetry`, `~/.config/aidd/telemetry`), `identity.json` (`%APPDATA%\aidd` or `~/.config/aidd`), the trailer delegate name and hook line, `aidd_docs/runs` and `AIDD_RUNS_DIR`.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | `grep -ril telemetry cli/src` finds nothing but comments explaining the `.aidd/config.json` file `clean` keeps |
+| 1 | `aidd --help` lists no `telemetry` command; `setup`, `plugin`, `clean`, `framework build` behave as before |
+| 2 | typecheck, lint, arch, knip, jscpd, the three tiers and the build with its budget are green |
+| 2 | No architecture baseline names a context or file that no longer exists |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-2.md
new file mode 100644
index 000000000..2f0932730
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-2.md
@@ -0,0 +1,102 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Remove the previous telemetry from the plugins, scripts, CI and docs
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+.
+โโโ plugins/aidd-telemetry/
+โ โโโ hooks/ โ journal.cjs, lib/, opencode-plugin.js, hooks.json (rebuilt in phase 6)
+โ โโโ skills/{00-init,01-cost,02-check}/ โ (rebuilt in phase 9)
+โ โโโ README.md โ๏ธ "being rebuilt" until phase 9
+โ โโโ CATALOG.md โ๏ธ regenerated
+โโโ plugins/aidd-pm/skills/04-spec/
+โ โโโ actions/01-build.md โ๏ธ no Declare step, no backlog-link test row
+โ โโโ references/backlog-link.md โ
+โ โโโ assets/backlog-link-template.json โ
+โโโ plugins/aidd-dev/skills/01-plan/
+โ โโโ actions/04-plan.md โ๏ธ no backlog-link step or test
+โ โโโ SKILL.md โ๏ธ no step-end marker
+โโโ plugins/aidd-orchestrator/skills/
+โ โโโ 01-sdlc/SKILL.md โ๏ธ no "Say when this orchestration is over"
+โ โโโ 01-sdlc/references/01-frame.md โ๏ธ no backlog-link paragraph
+โ โโโ 00-async-dev/SKILL.md โ๏ธ no step-end marker
+โ โโโ 02-backlog/SKILL.md โ๏ธ no step-end marker
+โโโ scripts/__tests__/aidd-telemetry-*.test.js, telemetry-*.test.js, a-backlog-link-*.test.js โ
+โโโ scripts/__tests__/fixtures/ (telemetry-only fixtures) โ
+โโโ scripts/lib/telemetry-reference-week.cjs, scripts/probe-identifier-join.cjs โ
+โโโ lefthook.yml โ๏ธ no prepare-commit-msg trailer job, no prompts-doc job if it only served telemetry
+โโโ .github/workflows/cli-ci.yml, ci.yml โ๏ธ no journal replay, no telemetry check, no telemetry e2e exclusion
+โโโ aidd_docs/product/{cost-report,metrics}-contract.md โ
+โโโ aidd_docs/runs/ โ
+โโโ aidd_docs/tasks/2026_09/{the-upward-link,flow-and-versions,install-surfaces-agree, fewer-verbs,one-place-per-nature,telemetry-at-scale} โ๏ธ status: superseded
+โโโ cli/aidd_docs/memory/telemetry.md, cli/.claude/skills/telemetry/ โ
+โโโ memory, README, docs/*.md, cli/README.md, cli/ARCHITECTURE.md โ๏ธ no claim about the previous telemetry
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Contributor commits on the repo] --> B[No trailer job runs, commit succeeds]
+ C[User runs the SDLC or spec or plan skill] --> D[No backlog-link.json written, no step-end echo]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Check out the branch with hooks installed => lefthook ready: 5: system
+ section Happy path
+ Run the repository scripts suite => green with no telemetry test left: 5: cli
+ Run pre-commit on a commit => every job green, no trailer job: 5: cli
+ section Edge case - dangling reference
+ A doc or skill still names a deleted file => markdown links and comments-name-files-that-exist fail: 1: cli
+```
+
+## Tasks to do
+
+### `1)` Empty the plugin and unhook the other skills
+
+> No plugin emits or writes anything for the previous telemetry.
+
+1. Delete the aidd-telemetry hooks and skills; leave `plugin.json` and a README saying the plugin is being rebuilt.
+2. Remove the backlog-link step, reference, template and test rows from `aidd-pm:04-spec` and `aidd-dev:01-plan`, and the paragraph from `01-sdlc/references/01-frame.md`.
+3. Remove every `aidd:step-end` echo and the SDLC's "Say when this orchestration is over" section.
+
+### `2)` Scripts, hooks, CI
+
+> The repository's own gates no longer mention the previous telemetry.
+
+1. Delete the telemetry script tests, their fixtures and the two telemetry scripts.
+2. Remove the `prepare-commit-msg` trailer job from `lefthook.yml`, and the prompts-doc job if telemetry was its only input.
+3. Remove the journal replay, `telemetry check` and telemetry e2e exclusion from the workflows.
+
+### `3)` Docs and task history
+
+> Docs describe what exists; past work stays readable as history.
+
+1. Delete the two product contracts, `aidd_docs/runs/`, the CLI telemetry memory and skill.
+2. Rewrite each memory, README and docs line that claims the previous telemetry (list in the explore notes) to say nothing or point at phase 9's docs.
+3. Mark the pending telemetry task folders `status: superseded`; leave done folders untouched.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | `grep -rn "backlog-link\|aidd:step-end" plugins` finds nothing |
+| 2 | `node --test 'scripts/__tests__/**/*.test.js'` and `lefthook run pre-commit` are green |
+| 2 | A commit in a clone that has a stale `aidd-session-trailer.sh` delegate, and one that has none, both succeed |
+| 3 | markdown-links, referenced-paths, comments-name-files-that-exist and the CATALOG/README count checks are green |
+| 3 | kanban shows no pending telemetry task from before this plan |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-3.md
new file mode 100644
index 000000000..f07a73e42
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-3.md
@@ -0,0 +1,93 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Count, Claude transcripts to usage records
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+cli/
+โโโ src/contexts/telemetry/
+โ โโโ domain/usage-record.ts โ the common record (context/usage-record-contract.md)
+โ โโโ domain/formats/claude-transcript-usage.ts โ one transcript line to candidate records, pure
+โ โโโ domain/usage-fold.ts โ global fold: one record per key, deterministic winner
+โ โโโ domain/ports/transcript-source.ts โ list files, read bytes from an offset
+โ โโโ infrastructure/claude-transcript-source-adapter.ts โ recursive walk of /projects
+โโโ tests/
+ โโโ fixtures/claude-usage/ โ ported from context/prototype/fixtures, synthetic ids only
+ โโโ contexts/telemetry/domain/*.unit.test.ts โ one test per rule, each with its mutation noted
+ โโโ contexts/telemetry/infrastructure/claude-transcript-source-adapter.integration.test.ts โ
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Transcripts: main, resumed copy, subagent, set-aside] --> B[Per-line candidates]
+ B --> C[Global fold by key]
+ C --> D[One usage record per billed call, advisor calls apart]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Copy the claude-usage fixtures to a temp config dir => transcripts in place: 5: system
+ section Happy path
+ Read and fold all transcripts => exactly the expected records, field for field: 5: system
+ Read the same files again, in reverse order => identical records: 5: system
+ section Edge case - streaming and resumed copies
+ Same key in three lines with growing totals => fold => the largest total wins: 1: system
+ section Edge case - advisor iterations
+ A message with advisor iterations => fold => one main record plus one advisor record per iteration: 1: system
+ section Edge case - cache writes across iterations
+ Later iteration writes 1h cache => fold => cache_write and cache_write_1h are iteration sums: 1: system
+ section Edge case - unknown shape
+ A usage line missing a counter => read => record field unknown and the shape reported, never zero: 1: system
+ section Edge case - unterminated last line
+ File ends mid-line => read => the partial line is not consumed: 1: system
+```
+
+## Tasks to do
+
+### `1)` The record and the per-line rules
+
+> A pure function turns one transcript line into zero or more candidate records.
+
+1. Define `UsageRecord` exactly as `context/usage-record-contract.md`, `tool: "claude-code"`; unknown is `null`.
+2. Key `message.id:requestId`, else `message.id:sessionId:timestamp`; drop ``; `agent` is `subagent` when `isSidechain` or `agentId`.
+3. `cache_write` and `cache_write_1h` summed over `iterations[type=message]`, top-level when there are none; `cache_write_1h` unknown when any split is missing.
+4. Each `iterations[type=advisor_message]` is its own record, key `#advisor`, its own model.
+5. A recognisable usage line with an unexpected shape yields an "unrecognised" outcome counted and surfaced, never a guessed record.
+
+### `2)` The global fold
+
+> Order-independent: one winner per key.
+
+1. Winner is the largest total; tie, the earliest `at`; tie, the lowest `session_id`.
+2. Fold across every file, not per file.
+
+### `3)` The transcript source
+
+> Every transcript under the projects root, read from a byte offset.
+
+1. Walk `/projects` recursively, including `subagents/` and `*.jsonl.superseded-*` set-asides.
+2. Read from a given byte offset; never consume an unterminated last line; report file size and identity (device+inode or equivalent) so a shrunk or replaced file is re-read whole.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | Each rule has a unit test that fails when that rule alone is mutated (iteration sum, advisor key, synthetic filter, sidechain agent, requestless key) |
+| 2 | Reading files in any order gives the same records; mutating the tie-break or folding per file turns a named test red |
+| 3 | Subagent and set-aside files are found; a file appended mid-line yields its complete lines only; a truncated file is re-read whole |
+| all | No fixture holds a real session id, prompt, path or name |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-4.md
new file mode 100644
index 000000000..2cf7accdd
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-4.md
@@ -0,0 +1,102 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Store, the ledger, ingest and consent
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+cli/
+โโโ src/
+โ โโโ cli.ts โ๏ธ register the telemetry group
+โ โโโ presentation/commands/telemetry.ts โ group + `ingest` (hidden from the skill surface, used by the hook)
+โ โโโ runtime/wiring/telemetry.ts โ
+โ โโโ runtime/git/git-adapter.ts โ๏ธ rev-parse roots, origin url, root commit, config get/set/unset, branch reflog creation
+โ โโโ contexts/telemetry/
+โ โโโ domain/repository-identity.ts โ hash(host/owner/repo) from origin, else root commit
+โ โโโ domain/telemetry-consent.ts โ .aidd/config.json telemetry.enabled + AIDD_TELEMETRY=0
+โ โโโ domain/ports/{usage-ledger,repository-locator,binding-snapshot-store}.ts โ
+โ โโโ application/ingest-usage-use-case.ts โ
+โ โโโ infrastructure/{usage-ledger-adapter,repository-locator-adapter,binding-snapshot-store-adapter}.ts โ
+โโโ tests/โฆ โ unit, integration, e2e (sandboxed HOME)
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[aidd telemetry ingest, or any telemetry command] --> B[Lock the ledger]
+ B --> C[Read new bytes of every transcript]
+ C --> D[Resolve each line's cwd to a repository]
+ D --> E{Repository opted in?}
+ E -- no --> F[Not stored]
+ E -- yes --> G[Upsert by key, fold, snapshot branch bindings, unlock]
+ D -- cwd gone, never seen alive --> H[Not stored, counted in coverage]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Sandbox HOME with fixture transcripts and two git repos, one opted in => ready: 5: system
+ section Happy path
+ Run aidd telemetry ingest twice => the ledger holds each key once, second run adds nothing: 5: cli
+ section Edge case - concurrent ingests
+ Two ingests start together => both finish => no duplicate, no torn line: 1: cli
+ section Edge case - stale lock
+ A lock left by a dead pid => ingest => lock cleared and ingest completes: 1: cli
+ section Edge case - deleted working dir
+ cwd seen alive at an earlier ingest, now deleted => ingest => resolved from the persisted resolution, stored: 1: cli
+ cwd never seen alive => ingest => not stored, counted as unresolved in coverage: 1: cli
+ section Edge case - refusal
+ AIDD_TELEMETRY=0, or the project not opted in => ingest => nothing stored for it: 1: cli
+ section Teardown
+ Remove the sandbox => baseline restored: 5: system
+```
+
+## Tasks to do
+
+### `1)` Repository resolution
+
+> Each line is tied to a repository at ingest, from its own cwd.
+
+1. realpath + case-fold the cwd, `git rev-parse --show-toplevel --git-common-dir`, cached per cwd.
+2. Repository id: hash of `host/owner/repo` parsed from `origin` (https and ssh, credentials stripped), else the root commit SHA; the raw url is never stored.
+3. Every resolution `cwd -> {repository id, root, consent}` is persisted in `/ledger/roots.json` the first time the cwd is seen alive, so lines from a worktree deleted later still resolve. A cwd never seen alive is not stored (its consent is unprovable) and is counted in coverage. Outside any repo: not stored, counted as `outside-repo` in coverage.
+4. Snapshot every opted-in repository's branch bindings (git config keys plus the branch creation time read from its reflog at that moment) into `/bindings/branches.jsonl`, append-only, on each ingest and each declaration. Attribution reads only snapshots, so a deleted or renamed branch keeps its past attribution.
+
+### `2)` The ledger
+
+> Append-only, idempotent, safe under concurrency.
+
+1. `/ledger/.jsonl` by record `at`; offsets in `/ledger/offsets.json`.
+2. Upsert by `(tool, key)` with the phase 3 fold; a newline guard before each append.
+3. Lock file with pid and creation time; a lock whose pid is gone or older than its timeout is cleared.
+4. Directory 0700 and files 0600 on POSIX, icacls on Windows, as `auth-storage` does.
+
+### `3)` Consent and ingest
+
+> Only opted-in projects are read into the ledger.
+
+1. Consent per repository root: `.aidd/config.json` `telemetry.enabled === true` **and** `telemetry.version === 2`. The previous version's bare `enabled: true` is not consent. `AIDD_TELEMETRY=0` refuses everything. Consent is checked when the cwd is first resolved.
+2. `ingest` is the one use case; every later telemetry command calls it first.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | https, ssh and credential-bearing remotes of one repo give one id; a repo without origin gets its root commit; a linked worktree resolves to its main repository's id |
+| 2 | Re-ingesting the same transcripts never changes the ledger; two concurrent ingests leave each key once |
+| 2 | A transcript rewritten shorter is re-read whole and the fold keeps the best record |
+| 3 | A project without consent, and any project under `AIDD_TELEMETRY=0`, stores nothing |
+| 1 | A branch deleted after its declaration still attributes its past records, read from the snapshot |
+| all | Each rule (key fold, lock clearing, consent refusal, snapshot) has a named mutation that turns its test red |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-5.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-5.md
new file mode 100644
index 000000000..da6df67d9
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-5.md
@@ -0,0 +1,89 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Declare, the task command and its stores
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+cli/src/
+โโโ presentation/commands/telemetry.ts โ๏ธ `task [--ticket ] | --none`, `task` alone shows the current binding
+โโโ contexts/telemetry/
+ โโโ domain/task-declaration.ts โ name, ticket, none, declared_at, by (command | hook-intercept)
+ โโโ domain/ports/{branch-binding-store,session-binding-store}.ts โ
+ โโโ application/declare-task-use-case.ts โ
+ โโโ infrastructure/
+ โโโ branch-binding-store-adapter.ts โ git config branch..aiddTask / aiddTicket / aiddDeclaredAt
+ โโโ session-binding-store-adapter.ts โ /bindings/sessions.jsonl, append-only
+scripts/__tests__/fixtures/telemetry-bindings/ โ the shared format fixture (read by CLI and hook tests)
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Person runs aidd telemetry task checkout-fix --ticket PROJ-12] --> B{Inside a Claude session?}
+ B -- yes --> C[Bind that session from now]
+ B -- no --> D[No session to bind]
+ C --> E{On a working branch?}
+ D --> E
+ E -- yes, not yet bound --> F[Bind the branch]
+ E -- default branch or detached --> G[Branch untouched]
+ F --> H[Print what was bound]
+ G --> H
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Sandbox repo on feat/x, CLAUDE_CODE_SESSION_ID set => ready: 5: system
+ section Happy path
+ aidd telemetry task checkout-fix --ticket PROJ-12 => session and branch bound, both printed: 5: cli
+ aidd telemetry task => shows the binding and where it comes from: 5: cli
+ section Edge case - none
+ aidd telemetry task --none => declared none, never asked again on this branch: 1: cli
+ section Edge case - redeclare
+ A second declaration in the same session => applies from its own time, the first keeps the earlier work: 1: cli
+ section Edge case - default branch
+ On the default branch => only the session is bound, git config untouched: 1: cli
+ section Edge case - outside a session
+ No session id in env => branch bound only: 1: cli
+```
+
+## Tasks to do
+
+### `1)` The declaration and its two stores
+
+> One declaration, stored where its scope lives.
+
+1. Ticket is free text, trimmed, never parsed or validated against a forge.
+2. Branch store: `git config --local branch..aiddTask|aiddTicket|aiddDeclaredAt`, then an immediate snapshot (phase 4, task 1.4) with the branch creation time read from the reflog now, before it can expire.
+3. Session store: one JSON line per declaration `{session_id, task, ticket, none, declared_at, by}`; the latest per session wins from its own time.
+4. Default branch (`origin/HEAD`, else `main`/`master` when no remote head) and detached HEAD never get a branch binding.
+
+### `2)` The shared format fixture
+
+> One file both languages test against.
+
+1. Fixture holds sample `sessions.jsonl`, `carries.jsonl` (phase 6) and the git config keys, with the expected "is this session bound, to what" answers, plus cases for every rule the hook duplicates: telemetry-dir resolution, default-branch detection, consent, and the Claude-only guard.
+2. A CLI unit test reads it; phase 6's hook test reads the same file.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | After a declaration on `feat/x`, `git config branch.feat/x.aiddTask` returns the task; renaming the branch keeps it |
+| 1 | On the default branch or detached HEAD, git config is unchanged |
+| 1 | Two declarations in one session are both kept, each with its own time |
+| 2 | Changing a field name in the CLI writer without updating the fixture turns the fixture test red |
+| all | Default-branch exclusion, latest-wins per session, and the none declaration each have a named mutation that turns their test red |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-6.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-6.md
new file mode 100644
index 000000000..25ec448a8
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-6.md
@@ -0,0 +1,126 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Ask, the plugin hooks
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+plugins/aidd-telemetry/hooks/
+โโโ hooks.json โ SessionStart (sync: session fact, clear carry), SessionStart (async: ingest), UserPromptSubmit
+โโโ session-start.cjs โ records {pid, session, source}; on clear, carries the predecessor's session binding
+โโโ catch-up.cjs โ async: runs `aidd telemetry ingest` if aidd is on PATH, prints nothing
+โโโ prompt-gate.cjs โ intercept a typed declaration, else ask when the rule says so
+โโโ lib/
+ โโโ claude-only.cjs โ payload session_id === CLAUDE_CODE_SESSION_ID, Claude-shaped transcript path
+ โโโ presence.cjs โ ATTENDED=1 and ENTRYPOINT cli or claude-desktop, else absent
+ โโโ binding.cjs โ reads git config + sessions.jsonl + carries.jsonl, answers "bound to what"
+ โโโ telemetry-dir.cjs โ AIDD_TELEMETRY_DIR, else AIDD_USER_CONFIG_DIR/telemetry, XDG, ~/.config/aidd/telemetry
+scripts/__tests__/aidd-telemetry-hooks.test.js โ spawns each hook with payloads and a sandbox env
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ P[Person submits a prompt] --> Q{Claude, opted in, person present?}
+ Q -- no --> R[Pass, silent]
+ Q -- yes --> S{Prompt is aidd telemetry task ...?}
+ S -- yes --> T[Run the CLI declaration, block with the result, original prompt suppressed]
+ S -- no --> U{Working branch and nothing bound?}
+ U -- no --> R
+ U -- yes --> V[Block: declare a task, two ways shown]
+ C[/clear] --> W[Session fact: same pid, new session]
+ W --> X[Copy predecessor's session binding as carried, announce it]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Temp repo on feat/x opted in, sandbox telemetry dir, Claude-shaped payloads => ready: 5: system
+ section Happy path
+ Prompt on unbound feat/x with ATTENDED=1 ENTRYPOINT=cli => blocked, reason names the command: 5: system
+ Prompt "aidd telemetry task fix-cart --ticket PROJ-1" => CLI called, blocked with confirmation, original prompt suppressed: 5: system
+ Next prompt => passes: 5: system
+ section Edge case - unattended
+ ATTENDED=0 or ENTRYPOINT=sdk-cli or variable absent => passes: 1: system
+ section Edge case - not Claude
+ Codex-shaped payload, or session_id differing from CLAUDE_CODE_SESSION_ID => passes, writes nothing: 1: system
+ section Edge case - default branch and detached
+ On main or detached HEAD => passes: 1: system
+ section Edge case - clear
+ SessionStart clear with the same CLAUDE_PID => carry written, systemMessage names the task: 1: system
+ section Edge case - opted out
+ Project not opted in, or AIDD_TELEMETRY=0 => every hook passes and writes nothing: 1: system
+ section Edge case - aidd missing
+ Declaration typed with no aidd on PATH => blocked with a message saying aidd is needed: 1: system
+```
+
+## Tasks to do
+
+### `1)` Guards shared by every hook
+
+> Inert outside Claude, outside opted-in projects, and when presence is unknown.
+
+1. Order for latency: env checks first (Claude-only, presence), then one file read (consent), then git. A person with the plugin but no consent pays no git call.
+2. Claude-only: payload `session_id` equals `CLAUDE_CODE_SESSION_ID` and the transcript path is under a Claude `projects/` directory (separators normalised).
+3. Consent read from `.aidd/config.json` at the repo root, `AIDD_TELEMETRY=0` refuses; the same rule as the CLI, pinned by a shared fixture case.
+4. Every hook exits 0 on any internal error, except the deliberate block; a crash never blocks a prompt.
+
+### `2)` The prompt gate
+
+> Ask once per working branch, answer without the model.
+
+1. A prompt matching `^\s*!?\s*aidd telemetry task\b` is tokenized by a strict parser (no shell syntax accepted), and the CLI is spawned with an argv array and no shell, `aidd.cmd` resolved explicitly on Windows; it then blocks with the CLI's output and `suppressOriginalPrompt: true`.
+2. Otherwise block only when: Claude, opted in, present, working branch, neither the session (declared or carried) nor the branch is bound, and the answer path works: `aidd` resolves and `aidd telemetry task --help` succeeds (checked only on this rare path). Otherwise pass silently: a block nobody can answer is never raised.
+3. The reason names both answers: `! aidd telemetry task [--ticket ]` in a terminal, the same text as a prompt anywhere.
+
+### `3)` Session facts and the clear carry
+
+> `/clear` keeps the task, visibly.
+
+1. On every SessionStart, append `{pid, session_id, source, at}` to the pid's history, pid from `CLAUDE_PID` (undocumented: pinned by a test, falling back to the parent pid as measured, and to no carry when neither is available).
+2. On `source: clear`, take the pid's latest session before this one; if it is bound (declared or carried), append `{session_id, from, at}` to `carries.jsonl` and print a `systemMessage` naming the task and how to change it.
+3. `resume` uses the resumed session's own binding; `fork` carries from the session it forked from when the history shows it (same work), probed once on `/branch`; `startup` and `compact` carry nothing.
+
+### `4)` Async catch-up
+
+> Ingest at session start, never visible, never blocking.
+
+1. A separate `async: true` SessionStart entry runs `aidd telemetry ingest` when `aidd` resolves on PATH; prints nothing to stdout.
+2. Assert that `async: true` survives both Claude install routes (marketplace copy and the flat settings merge).
+
+### `5)` Presence on non-terminal surfaces
+
+> Know, not assume, what the IDE and desktop app expose.
+
+1. Probe once, with the maintainer, in the VS Code extension and the desktop app: `CLAUDE_CODE_SESSION_ATTENDED`, `CLAUDE_CODE_ENTRYPOINT`, and whether a block reason displays. Record the values in `context/target.md`.
+2. Extend the presence rule only with values observed there; until observed, those surfaces never block.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | A Codex-shaped payload and a mismatched session id both pass and write no file |
+| 1 | A hook given malformed JSON exits 0 and passes the prompt |
+| 2 | An unbound working branch with a person present blocks; the same with ATTENDED=0 passes |
+| 2 | A typed declaration reaches no model: the hook blocks and the CLI has stored it |
+| 3 | After a clear on a bound session, the new session reads as bound to the same task, marked carried |
+| 3 | The hook reads the phase 5 shared fixture and returns the same answers as the CLI test |
+| 4 | The catch-up writes nothing to stdout and exits 0 when aidd is absent |
+| 4 | A flat-merged `.claude/settings.json` keeps `async: true` on the catch-up entry |
+| 2 | With `aidd` absent or lacking `telemetry task`, an unbound branch is never blocked |
+| 2 | A typed declaration containing `;`, `&&`, a pipe or backticks runs no second command |
+| 5 | Observed IDE and desktop values are recorded, with a test per observed value |
+| all | Claude-only guard, presence rule, answer-path check and clear carry each have a named mutation that turns their test red |
+| all | The suite passes on the Windows CI job |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-7.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-7.md
new file mode 100644
index 000000000..abfee804e
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-7.md
@@ -0,0 +1,92 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Attribute and report
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+cli/src/
+โโโ presentation/commands/telemetry.ts โ๏ธ `report [--from --to | --days] [--axis ] [--json]`
+โโโ presentation/display/telemetry-report-display.ts โ text rendering only
+โโโ contexts/telemetry/
+ โโโ domain/attribution.ts โ pure: record + bindings + carries + branch facts -> task/ticket or unattributed(reason)
+ โโโ domain/usage-report.ts โ totals and axes, four counters apart
+ โโโ domain/person.ts โ opted-in person id or none
+ โโโ domain/ports/person-identity.ts โ
+ โโโ application/report-usage-use-case.ts โ ingest, then attribute and aggregate
+ โโโ infrastructure/person-identity-adapter.ts โ userConfigDir()/telemetry/identity.json
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[aidd telemetry report --days 7] --> B[Ingest]
+ B --> C[Attribute every record]
+ C --> D[Aggregate by axis]
+ D --> E[Totals, one row per value, unattributed rows with reasons, coverage line]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Ledger fixture spanning two days, two models, two branches, one carry, one redeclaration => ready: 5: system
+ section Happy path
+ report --axis task => one row per task, unattributed rows by reason, sum equals total: 5: cli
+ report on every axis => each sums to the same total, four counters apart: 5: cli
+ section Edge case - retroactive branch
+ Records on feat/x before it is declared => report => unattributed no-binding; declare; report => on the task: 1: cli
+ section Edge case - carried then corrected
+ A carried session later declared => report => the whole session on the declared task: 1: cli
+ section Edge case - mid-session redeclaration
+ Second declaration at t => report => before t on the first task, after t on the second: 1: cli
+ section Edge case - unknown counters
+ A record with an unknown counter => report => that counter shown unknown for the row, never summed as zero: 1: cli
+```
+
+## Tasks to do
+
+### `1)` Attribution
+
+> One pure function, the command the only source.
+
+1. Order: session declaration effective at the record's time; else the session's carry (whole session until its first declaration); else the branch binding read from the snapshots, when the record's `git_branch` and repository match and its time is after the branch creation time captured in the snapshot; else unattributed.
+2. A first declaration in a carried session applies to the whole session; later ones apply from their own time.
+3. Unattributed reasons: `outside-repo`, `root-unresolved`, `no-binding`, `declared-none`.
+4. Subagent and advisor records inherit their parent session's attribution through `session_id`.
+
+### `2)` The report
+
+> Every axis reconciles to one total.
+
+1. Axes: `total`, `person`, `session`, `model`, `day` (UTC), `repository`, `task`, `ticket`.
+2. Four counters per row, and a row total; an unknown counter is shown as unknown for its row and excluded from no total silently: the report states how many records had an unknown.
+3. A coverage line: files read, records, unrecognised shapes, and the oldest transcript date still on disk.
+4. `--json` emits a versioned envelope documented in phase 9.
+
+### `3)` Person
+
+> Opt-in, fresh, local.
+
+1. `aidd telemetry identity ` writes `{person_id}` under the telemetry dir; `--off` removes it; absent means no person axis value.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | Each attribution rule has a unit test and a named mutation that turns it red |
+| 1 | Declaring a branch after its work moves that work from `no-binding` to the task on the next report |
+| 2 | For every axis, the rows sum to the total on the fixture and on a property-based generated ledger |
+| 2 | The JSON envelope carries its version, and the text never shows a path, cwd or branch name it does not need |
+| 3 | Without an identity file, the person axis shows one "not set" row; with one, the id |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-8.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-8.md
new file mode 100644
index 000000000..5ec4b0e38
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-8.md
@@ -0,0 +1,85 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Opt in, opt out, forget, and what V1 left behind
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+cli/src/
+โโโ presentation/commands/telemetry.ts โ๏ธ `on [--yes]`, `off`, `forget [--yes]`
+โโโ contexts/telemetry/
+ โโโ domain/legacy-leftovers.ts โ what V1 wrote, by location
+ โโโ application/{telemetry-on,telemetry-off,forget-telemetry}-use-case.ts โ
+ โโโ infrastructure/legacy-leftovers-adapter.ts โ finds and removes them
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[aidd telemetry on] --> B[Write telemetry.enabled]
+ B --> C[Remove V1 leftovers in this repo]
+ C --> D[Report Claude transcript retention and the one-line fix]
+ E[aidd telemetry forget] --> F[Show what would go]
+ F -- --yes --> G[Remove ledger, bindings, identity, V1 sink and identity]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Sandbox repo carrying V1 leftovers (trailer line + delegate, aidd_docs/runs, ignore entry) and a V1 sink + identity => ready: 5: system
+ section Happy path
+ aidd telemetry on --yes => enabled, trailer line and delegate gone together, runs dir and ignore entry gone: 5: cli
+ git commit => succeeds, no AIDD-Session-Id trailer: 5: cli
+ aidd telemetry forget --yes => ledger, bindings, identity, V1 day files and V1 identity removed: 5: cli
+ section Edge case - foreign hook content
+ prepare-commit-msg has other lines => on => only the trailer line removed, the rest intact: 1: cli
+ section Edge case - hook managers
+ lefthook or husky manages the hook => on => the delegate is kept if a manager line still calls it, and the manual step is printed: 1: cli
+ section Edge case - forget preview
+ forget without --yes => lists what would go, removes nothing: 1: cli
+ section Edge case - retention
+ Claude cleanupPeriodDays unset or below 3650 => on => says so and prints the setting to add, writes nothing in the Claude profile: 1: cli
+ section Teardown
+ Remove the sandbox => baseline restored: 5: system
+```
+
+## Tasks to do
+
+### `1)` Opt in and V1 leftovers
+
+> Enabling leaves the repository as if V1 had never run.
+
+1. `on` writes `.aidd/config.json` `telemetry: {enabled: true, version: 2}`, keeping other keys; `--yes` skips the confirmation. A repository carrying only the previous version's `enabled: true` stays off until `on` runs.
+2. Remove the `prepare-commit-msg` line calling `aidd-session-trailer.sh` and the delegate script in the same step; never remove the script while a line still calls it.
+3. Remove `aidd_docs/runs/` (V1 journal, git-ignored) and its `.gitignore` entry.
+4. Read Claude's effective `cleanupPeriodDays` (user and project settings, read only) and print the fix when it is below 3650.
+
+### `2)` Opt out and forget
+
+> Off stops reading; forget erases.
+
+1. `off` sets `telemetry.enabled: false`, keeping `version`; nothing else changes.
+2. `forget` previews by default; `--yes` removes the ledger, bindings, carries, identity, V1 day files at the telemetry dir root and V1's `identity.json`, and the git config `aiddTask|aiddTicket|aiddDeclaredAt` keys of every still-existing repository the binding snapshots name.
+3. The previous version's locations come from phase 1's notes: sink dir under `AIDD_TELEMETRY_DIR`, `AIDD_USER_CONFIG_DIR/telemetry`, `%APPDATA%\aidd\telemetry` on Windows, `~/.config/aidd/telemetry`; identity at `%APPDATA%\aidd\identity.json` or `~/.config/aidd/identity.json`.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | After `on` in a V1 repo, a commit succeeds and carries no AIDD-Session-Id trailer |
+| 1 | A prepare-commit-msg with other content keeps that content byte for byte |
+| 1 | Nothing under the real or sandboxed Claude profile is written |
+| 2 | `forget` without `--yes` changes nothing on disk; with it, nothing of either version remains in the telemetry dir |
+| all | Trailer line-and-script pairing, foreign-content preservation and forget's preview each have a named mutation that turns their test red |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-9.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-9.md
new file mode 100644
index 000000000..f8a9c7944
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-9.md
@@ -0,0 +1,85 @@
+---
+status: implemented
+---
+
+
+
+# Instruction: Skills, docs, and the end-to-end proof
+
+## Architecture projection
+
+> Tree of the final files. โ create ยท โ๏ธ modify ยท โ delete
+
+```txt
+.
+โโโ plugins/aidd-telemetry/
+โ โโโ skills/00-init/ โ opt in, identity, forget, through the CLI
+โ โโโ skills/01-usage/ โ answer "how many tokens" questions with `aidd telemetry report`
+โ โโโ README.md โ๏ธ what it measures, what it asks and when, what stays local
+โ โโโ CATALOG.md โ๏ธ regenerated
+โโโ aidd_docs/product/usage-contract.md โ the usage record, the report envelope, the binding stores
+โโโ cli/aidd_docs/memory/telemetry.md โ how the context is built
+โโโ memory, README, docs/*.md โ๏ธ the new telemetry, one line each where relevant
+โโโ cli/tests/e2e/telemetry-journey.e2e.test.ts โ the full journey on the built binary
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[Opt in] --> B[Work: asked once on a working branch]
+ B --> C[/clear, keep working, carried/]
+ C --> D[Headless run on the same branch]
+ D --> E[Report: per task, ticket, model, day, session, person, repository]
+ E --> F[Every axis equals the total]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ Built binary, sandbox HOME and CLAUDE_CONFIG_DIR, a repo on feat/x, synthetic transcripts for three sessions => ready: 5: cli
+ section Happy path
+ on, hook block, typed declaration, clear carry, headless session, report --json => each axis sums to the total, tokens land on the declared task and ticket: 5: cli
+ section Edge case - real history oracle
+ Ingest a copy of local history in a sandbox => daily totals equal an independent count of the same transcripts, the reference's cache-write under-count explained: 1: cli
+ section Teardown
+ Delete the sandbox => no file outside it touched: 5: system
+```
+
+## Tasks to do
+
+### `1)` Skills
+
+> Two skills, both thin over the CLI, saying loudly when it is absent.
+
+1. `00-init`: opt in after consent, set or remove the identity, forget with a preview.
+2. `01-usage`: map a question to `aidd telemetry report` flags and read the JSON; never compute a number itself.
+
+### `2)` Docs
+
+> One contract, written once.
+
+1. `aidd_docs/product/usage-contract.md`: the record, the envelope version, the stores and their owners, the ask rule, the carry rule.
+2. Plugin README, CLI memory and repo memory lines.
+3. Set the bundle budget once, to the measured build plus about 2 %, with its registry line.
+
+### `3)` Proof
+
+> The whole journey on the built binary, and one comparison on real data.
+
+1. One e2e journey covering the spec's done-when list, no AI tool binary on PATH.
+2. A manual, documented oracle run on a copied history in a sandbox, comparing daily totals with an independent count; its output kept in this folder, with no personal data.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------- |
+| 1 | Each skill's actions name only commands that exist in the CLI help golden |
+| 2 | Every doc link resolves; no doc mentions the previous telemetry's journal, envelope or commands |
+| 3 | The e2e journey passes on Linux, macOS and Windows CI |
+| 3 | The oracle note shows equal totals for every compared day, or each difference explained to the token |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/plan.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/plan.md
new file mode 100644
index 000000000..1905318ee
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/plan.md
@@ -0,0 +1,57 @@
+---
+objective: "Every billed Claude Code call is counted once, stored locally, and broken down by person, session, model, period, repository, task and ticket, the task coming only from a declaration, with the previous telemetry gone."
+status: implemented
+---
+
+
+
+# Plan: Deterministic token measurement, release 1
+
+## Overview
+
+| Field | Value |
+| ---------- | --------------------------------------------------------- |
+| **Goal** | Replace the previous telemetry with a deterministic, declaration-attributed token count for Claude Code |
+| **Source** | [`spec.md`](./spec.md), with [`context/target.md`](./context/target.md) and [`context/usage-record-contract.md`](./context/usage-record-contract.md) |
+
+## Phases
+
+| # | Phase | File |
+| --- | -------------------------------------------------- | ---------------------------- |
+| 1 | Remove the previous telemetry from the CLI | [`phase-1.md`](./phase-1.md) |
+| 2 | Remove it from the plugins, scripts, CI and docs | [`phase-2.md`](./phase-2.md) |
+| 3 | Count: Claude transcripts to usage records | [`phase-3.md`](./phase-3.md) |
+| 4 | Store: the ledger, ingest, consent | [`phase-4.md`](./phase-4.md) |
+| 5 | Declare: the task command and its stores | [`phase-5.md`](./phase-5.md) |
+| 6 | Ask: the plugin hooks | [`phase-6.md`](./phase-6.md) |
+| 7 | Attribute and report | [`phase-7.md`](./phase-7.md) |
+| 8 | Opt in, opt out, forget, and what V1 left behind | [`phase-8.md`](./phase-8.md) |
+| 9 | Skills, docs, and the end-to-end proof | [`phase-9.md`](./phase-9.md) |
+
+Phases 1 and 2 land as their own commits but never ship alone: the branch merges only once
+phase 9 is green, so `next` never carries a CLI without measurement.
+
+## Resources
+
+| Source | Verified |
+| ------ | -------- |
+| https://code.claude.com/docs/en/hooks | `UserPromptSubmit` blocks before the model; a blocked prompt's message ends with `Original prompt:` and is written to the transcript unless `hookSpecificOutput.suppressOriginalPrompt` is true; it also fires on turns Claude starts on its own, with no field telling them apart; `async: true` cannot block and its `systemMessage`/`additionalContext` go to the model on the next turn; `-p` kills a running async hook at teardown; `SessionStart.source` is `startup \| resume \| clear \| compact \| fork` |
+| https://code.claude.com/docs/en/env-vars | `CLAUDE_CODE_SESSION_ID` is set in hooks and Bash tool subprocesses, matches the hook's `session_id`, and is updated on `/clear`. `CLAUDE_CODE_ENTRYPOINT` and `CLAUDE_CODE_SESSION_ATTENDED` are not documented |
+| https://learn.chatgpt.com/docs/hooks | Codex `UserPromptSubmit` can block (`decision: block` or exit 2): Codex delivers the same event name, so the hook must refuse non-Claude hosts itself |
+| Sandbox probes, Claude Code 2.1.295 ([`context/target.md`](./context/target.md)) | `ATTENDED=1`/`ENTRYPOINT=cli` interactive, `ATTENDED=0`/`sdk-cli` under `-p`; `CLAUDE_CODE_CHILD_SESSION=1` in both; `!` commands see the session id and fire no `UserPromptSubmit`; `/clear` changes the session id, keeps `CLAUDE_PID`, carries no previous id; a hook `systemMessage` shows as `SessionStart:clear says: โฆ`; a blocked `-p` exits 0; a commit hook line naming a missing script fails the commit |
+
+## Decisions
+
+| Decision | Why |
+| -------- | --- |
+| The CLI owns reading, storing, attributing and reporting; the plugin hooks only ask, intercept the answer, and record session facts | One implementation per concern. The hook runs on every prompt, so it stays plain node with no `aidd` spawn on the common path (61 ms against 241 ms measured for V1) |
+| The declaration command is `aidd telemetry task [--ticket ] \| --none` | The task exists only for measurement; it lives under the measurement command group, not as a new top-level verb |
+| A branch declaration is stored in the repository's own git config (`branch..aiddTask`, `aiddTicket`, `aiddDeclaredAt`); session declarations and `/clear` carries are stored in the person's telemetry directory | git config follows a branch rename and dies with the branch, and it needs no file in the work tree; a session is per person and machine |
+| The hook acts only when the payload's `session_id` equals `CLAUDE_CODE_SESSION_ID` and the payload is Claude-shaped | Codex and Copilot deliver `UserPromptSubmit` under the same name, and a Codex nested in Claude inherits Claude's variable: equality makes the hook inert elsewhere by construction |
+| Block only when `CLAUDE_CODE_SESSION_ATTENDED=1` and `CLAUDE_CODE_ENTRYPOINT` is `cli` or `claude-desktop`, the surfaces seen to show a block's reason; anything else never blocks | Both variables are undocumented; requiring both to agree, and failing open, means a change in Claude can only stop the asking, never block work wrongly. IDE extensions are not asked: VS Code shows no hook message (phase 6 task 5), a deviation recorded in the spec |
+| The format the CLI writes and the hook reads (bindings, carries) is pinned by one shared fixture under `scripts/__tests__/fixtures/telemetry-bindings/`, read by a CLI test and a hook test | Two languages, one format: the fixture is the contract, so neither side can drift silently |
+| Ledger under `/ledger/`, where the telemetry dir is `AIDD_TELEMETRY_DIR`, else `userConfigDir()/telemetry` | A subdirectory keeps it apart from V1's day files at the root, which `forget` removes; `userConfigDir()` honours `AIDD_USER_CONFIG_DIR`, which V1's identity did not |
+| **Superseded by the next row (round 4).** Consent is `git config --local aidd.telemetry` equal to `2`, per clone, never committed; nothing in `.aidd/config.json` counts, the CLI and the hooks alike; `on` removes the previous version's `telemetry` block from that file | A tracked file would opt in every teammate who pulls it; the common git config is shared by linked worktrees and committed nowhere. Nobody is asked or measured without having run `on` in their own clone |
+| Consent is a key `git config --local aidd.telemetry 2:` **and** an open interval in `ledger/consents.jsonl` naming that token for that very clone (`on` mints the token, appends the interval, and the first `on` backfills nothing). A call is stored only when its directory was seen alive in clone X, X had an open interval at the call's time, and `AIDD_TELEMETRY=0` is not set. Ingest and the hooks close an interval when they see the key stop naming it. User decision, round 4: `on` no longer catches up history, and a call is stored only if its clone's consent was open at the call's time | Four rounds of review found consent holes in every version that read history back: a first `on` backfilled calls an old clone made at the same path, an `off` window came back with `forget` or a move, a manual `off` was invisible until an ingest. Intervals keyed by an identity and a per-interval token, closed by whoever sees the key change, leave nothing to infer from a path, a remote or the order of ingests. Nothing before `on` is wanted, so nothing before `on` is read. A hand-set key, a `cp -R` copy, a re-clone or a moved clone is not consenting for the hooks either, so nothing is asked where nothing is measured |
+| Skills in other plugins never call the telemetry command in release 1 | A plugin must not depend on the telemetry plugin; the hook's ask covers every case. A skill-side pre-emption is a follow-up |
+| `task`, `on` and `identity` do not ingest first, against phase 4 task 3.2 | Hook latency: a declaration is a few lines appended and needs no new usage, and it must never wait on an ingest. `report` and `ingest` still read first |
diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md
new file mode 100644
index 000000000..ed0382b0b
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md
@@ -0,0 +1,108 @@
+---
+status: framed
+branch: feat/telemetry-deterministic-tokens
+---
+
+# Deterministic token measurement, release 1
+
+## Target
+
+On a machine running Claude Code, every billed model call is counted exactly once and can be
+broken down by person, session, model, period, repository, task and ticket, the task and ticket
+coming only from what a person declared, with the previous telemetry removed entirely.
+
+## Hard constraints
+
+- The previous telemetry is removed, not adapted: its journal, its hooks, its records and
+ their stored format, its report envelope, its commands and checks, the backlog link a task
+ folder declares, the step markers skills emit, and the commit trailer. Nothing of it is read,
+ migrated or kept compatible.
+- Removal never breaks a user's repository. Opting in with the new version removes what the
+ previous one left in that repository (the commit hook line together with its script, the run
+ journal and its ignore entry); forgetting also removes the previous version's stored
+ measurement and identity. A commit hook line pointing at a missing script fails every commit
+ (measured), so the line and its script are only ever removed together.
+- Counting is deterministic: the same transcripts give the same totals on every run, in any
+ order, read once or many times. Each billed call is counted once, whichever copies of it the
+ tool wrote (streaming snapshots, resumed sessions, sub-agents, advisor calls).
+- Input, output, cache read and cache write are always reported apart. An unknown value is
+ unknown, never zero. A shape the reader does not recognise is reported, never guessed.
+- One per-tool reader turns a tool's own files into a common usage record; everything after
+ it (storage, attribution, report, export) is written once for every tool.
+- A task and its ticket are known only from an explicit declaration by a command. No branch
+ name, folder name, commit message or prompt text is ever parsed to guess one.
+- The declaration is asked for at most once per working branch, only of a person present, and
+ before any token of that work is spent, on every surface where a person can be present
+ (terminal, IDE extension, desktop app), including those without a shell mode. Answering is
+ never itself blocked and never reaches the model. A session with nobody present, or whose
+ presence is unknown, is never blocked.
+ > **Deviation, 2026-10-12, maintainer decision.** IDE extensions are not asked: VS Code shows
+ > no hook message, so a block would eat the prompt. Declare in a terminal; it binds the branch.
+ > Evidence: [target.md](context/target.md).
+- A session started by `/clear` keeps the task of the session it replaced, says so to the
+ person, and a later declaration in it re-attributes the whole session.
+- Work with no declared task is its own row, with the reason it has none, and is attributed
+ retroactively once its branch is declared.
+- Every breakdown of a period sums to the same total.
+- A clone or person that has not opted in is untouched: nothing stored, nothing asked,
+ nothing blocked. Consent is per clone, in its git config, never committed, so a teammate is
+ never measured without running `aidd telemetry on` themselves. Measurement starts at `on`: a call is stored only if its clone's consent was open at the call's time, so what a clone's sessions did before `on`, or while it was off, is never counted. A key set by hand, a copied clone or a moved one is not consent. Without the plugin, nothing changes at all.
+- Data stays on the machine: release 1 sends nothing anywhere.
+- The person identity is a fresh, separate opt-in, located with the rest of the person's aidd
+ configuration; the previous identity file is not carried over.
+- Public repository: no real session id, prompt, machine path or personal name in any fixture.
+- Every rule ships with the test that failed first and the mutation that proves the test.
+- All gates green, Windows included.
+
+## Non-goals
+
+- Codex, Copilot, Cursor, opencode and Kilo measurement. Their measurement disappears with the
+ previous telemetry until their own reader lands; the common record already admits them.
+- Amounts in currency: the destination owns the price table (decision record of 2026-09-01).
+- Sending anything to the hosted destination (Gouvernail), or defining its record: designed
+ later, with the destination.
+- Aggregating across machines or people: the destination's job.
+- Cloud sessions, whose transcripts are not on the machine.
+- Step, skill, flow, prompt and produced-file breakdowns.
+- Guessing the task from the work itself.
+
+## Done-when
+
+- No trace of the previous telemetry remains in the shipped product, its tests or its docs,
+ except completed task folders kept as history, and pending ones are marked superseded.
+- A user who had the previous version enabled can still commit before and after opting in
+ again; after opting in, nothing the previous version left remains in that repository, and
+ after forgetting, nothing remains in their profile either.
+- On the shared fixtures, every counting rule gives the expected per-call record, and breaking
+ any one rule turns its own test red.
+- On real local history, the release's daily totals equal an independent reference count of
+ the same transcripts, the known reference under-count of cache writes aside and explained.
+- A person present on an unbound working branch, on every surface where presence is
+ detectable (the terminal at least), cannot get a model answer until they declare a task or declare none, and the declaration itself costs no model
+ call; the default branch and a detached HEAD never ask.
+- A headless run on the same branch completes unblocked and its tokens land on the branch's
+ task, or on the unattributed row until the branch is declared, then on the task.
+- After `/clear`, the new session's tokens land on the carried task, and a declaration made in
+ it moves all of them.
+- A report for a period shows tokens per person, session, model, day, repository, task and
+ ticket, four counters apart, every axis summing to the same total, unattributed work on its
+ own row with its reason.
+- A project that never opted in shows no prompt, no block, and nothing stored.
+
+## Open Questions
+
+None.
+
+## Stakeholders
+
+- Decider: the maintainer who framed release 1.
+- Owner: the aidd-telemetry plugin and the `aidd` CLI.
+- Consumer: developers using Claude Code with the plugin; the Gouvernail destination.
+
+## Context
+
+- Measurements, verified behaviours and the target shape: [`context/target.md`](context/target.md).
+- The common usage record and per-tool normalisation: [`context/usage-record-contract.md`](context/usage-record-contract.md).
+- Prototype readers and their mutation-proved fixtures: [`context/prototype/`](context/prototype/).
+- Decision records kept: [hosted destination](../../../memory/internal/decisions/measurement-may-reach-a-hosted-destination.md),
+ [price table](../../../memory/internal/decisions/a-price-table-is-the-destination-s.md).
diff --git a/cli/.claude/rules/00-architecture/0-contexts.md b/cli/.claude/rules/00-architecture/0-contexts.md
index 09d716079..7223cf2c1 100644
--- a/cli/.claude/rules/00-architecture/0-contexts.md
+++ b/cli/.claude/rules/00-architecture/0-contexts.md
@@ -23,7 +23,6 @@ Organise by bounded context, never by layer.
- Keep only shared vocabulary: types, pure helpers, typed errors.
- A port two contexts need lives in `kernel/ports/`.
- A domain decision is never kernel material.
-- Measurement vocabulary sits in `kernel/measurement.ts`; `tools` never imports `telemetry`.
## Interior
diff --git a/cli/.claude/skills/framework/SKILL.md b/cli/.claude/skills/framework/SKILL.md
index cad1cfce3..573619067 100644
--- a/cli/.claude/skills/framework/SKILL.md
+++ b/cli/.claude/skills/framework/SKILL.md
@@ -4,7 +4,7 @@ description: >
Owns the installation record and everything done to a project, under
src/contexts/framework/ โ the manifest aggregate, and the setup/install/restore/uninstall/doctor
orchestration built on top of it. This is the only context allowed to reach `translate` and
- `distribution`; `tools` is reachable from `telemetry` too. Use when adding a use-case that touches the manifest, a
+ `distribution`. Use when adding a use-case that touches the manifest, a
setup/doctor/sync/uninstall flow, a new top-level CLI orchestration, or a launcher that runs an
external binary (kanban-shaped). Do NOT use for a tool's own profile or capability classes โ
use `tools`. Do NOT use for the translation pipeline โ use `translate`. Do NOT use for where
@@ -16,7 +16,7 @@ description: >
`framework` is what is posed on a project and the record of it: the manifest that tracks every
installed file, and every flow that reads or changes that record โ setup, doctor, sync (restore),
uninstall, plugin install/update/remove, and the global chain orchestrators. It is the one
-context the dependency chain lets reach every other context but `telemetry` (`framework โ
+context the dependency chain lets reach every other context (`framework โ
translate โ tools โ kernel`, plus `framework โ distribution`), because assembling what goes on
disk is exactly the job that needs all three.
diff --git a/cli/.claude/skills/framework/references/post-install-pipeline.md b/cli/.claude/skills/framework/references/post-install-pipeline.md
index 895f5875c..caaa4367a 100644
--- a/cli/.claude/skills/framework/references/post-install-pipeline.md
+++ b/cli/.claude/skills/framework/references/post-install-pipeline.md
@@ -6,7 +6,7 @@
never with its steps inline. A use case that only changes the manifest saves it directly.
**Steps, in order**: `manifestRepo.save()`, then `GitignoreUseCase.execute()` with
-`.aidd/cache/`, every installed tool's machine-local files, and `aidd_docs/runs/`.
+`.aidd/cache/` and every installed tool's machine-local files.
```typescript
import { PostInstallPipelineUseCase } from "../install/post-install-pipeline-use-case.js";
diff --git a/cli/.claude/skills/telemetry/SKILL.md b/cli/.claude/skills/telemetry/SKILL.md
deleted file mode 100644
index 00b365c58..000000000
--- a/cli/.claude/skills/telemetry/SKILL.md
+++ /dev/null
@@ -1,68 +0,0 @@
----
-name: telemetry
-description: >
- Owns what a session cost and who it was for, under src/contexts/telemetry/ โ reading a tool's
- own local files, attributing a figure to a person, a task, a flow or a step, and the sink that
- keeps records per machine. Use when adding a reader for another tool's transcript format,
- changing how a figure is attributed or reported, touching the sink or the identity store, or
- wiring a telemetry port. Do NOT use for what a tool declares about being measured โ that is
- `kernel/measurement.ts`, read via the `tools` skill. Do NOT use for installing or removing the
- hook that writes the run journal into a project โ that record belongs to `framework`.
----
-
-# Telemetry
-
-`telemetry` answers two questions about work already done: what did it cost, and whose was it.
-It never causes the work and never installs anything on a project's behalf. Everything it reads
-already exists on disk because a tool wrote it, so the whole context is a set of readers, a set
-of attribution rules, and one sink.
-
-Where the sink and the run journal live, what the report renders, and what each tool declares
-about being measured are in `aidd_docs/memory/telemetry.md`. Read that for the facts; this page
-says where new code goes and what it must not do.
-
-## What goes in
-
-| Concept | Location |
-|---|---|
-| A rendered answer's shape | `domain/cost-report.ts`, `domain/cost-report-envelope.ts` |
-| How a figure is tied to something | `domain/step-attribution.ts`, `domain/task-attribution.ts`, `domain/flow-attribution.ts` |
-| Reading one tool's own file format | `domain/formats/` (one module per tool's transcript or export) |
-| A stored record and what happens to it over time | `domain/telemetry-sink-record.ts`, `domain/telemetry-sink-retention.ts` |
-| Who a session was for, and how strongly that is known | `domain/person-resolution.ts`, `domain/ports/person-identity-reader.ts`, `domain/ports/person-identity-store.ts` |
-| Something telemetry needs from outside itself | `domain/ports/` โ declared here, satisfied at the composition root |
-| The concrete reader behind one of those ports | `infrastructure/` |
-| One question the `aidd telemetry` command asks | `application/` |
-
-## How
-
-- **A tool declares, telemetry reads.** What a route was measured to supply, and where a tool's
- transcripts live, are declared in `kernel/measurement.ts` and filled in per tool under
- `contexts/tools/domain/profiles//`. Every field there is required on purpose: a default
- would be a capability nobody measured, quietly asserted for a tool nobody looked at. Never add
- a branch on a tool id inside this context.
-- **What telemetry needs from another context, it declares as its own port.** `installed-plugins-reader.ts`
- and `ignore-entries.ts` are the pattern: telemetry states the question, `runtime/wiring/telemetry.ts`
- hands it an answer, and no context reaches into telemetry in return.
-- A figure with no established denomination is not an amount. A zero whose denomination was never
- established, a credit and a premium request are each their own thing; conflating them is how a
- report lies without ever being wrong about a number.
-- An interval derived from the run journal is this CLI's inference, not the tool's statement.
- Keep the two distinguishable in whatever you add โ `toolStatedStep` exists for exactly that.
-- Follow the use-case and port/adapter rules in `.claude/rules/00-architecture/`.
-
-## Public surface
-
-`tests/architecture/context-boundary.arch.test.ts` holds the list (`PUBLIC_MODULES.telemetry`):
-the use cases the `telemetry` command drives, the shapes a rendered answer is made of, the
-commit-trailer format the git adapter writes, and the two ports a caller wires a concrete
-adapter into (`domain/ports/telemetry-sink.ts`, `domain/ports/version-control.ts`). Nothing
-else, and no adapter. Rendering happens in `presentation/display/`, never here.
-
-## How it's tested
-
-- `tests/contexts/telemetry/` mirrors `src/contexts/telemetry/` โ a format reader and an
- attribution rule are unit-tier; an adapter against a real temp filesystem is integration-tier.
-- A reader for a new tool's format needs a fixture captured from that tool's real output. A
- format module tested only against a fixture this repository wrote proves the parser, not the
- format. See the `test` skill before touching a golden snapshot.
diff --git a/cli/.claude/skills/test/SKILL.md b/cli/.claude/skills/test/SKILL.md
index 4c8b82927..5b91d08b8 100644
--- a/cli/.claude/skills/test/SKILL.md
+++ b/cli/.claude/skills/test/SKILL.md
@@ -6,7 +6,7 @@ description: >
user-reported bug. Do NOT use for tier conventions, the vitest projects, doubles, fixtures or
how to run a suite โ those live in `aidd_docs/memory/testing.md`. Do NOT use for writing
production code โ use the context skill that owns the concept (`tools`, `translate`,
- `distribution`, `framework`, `telemetry`).
+ `distribution`, `framework`).
---
# Test
diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md
index c25fa2ffd..eabd07e49 100644
--- a/cli/ARCHITECTURE.md
+++ b/cli/ARCHITECTURE.md
@@ -108,7 +108,7 @@ Memory ownership (CLAUDE.md, AGENTS.md, copilot-instructions.md) is delegated to
## Hook adaptation
-OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization; telemetry follows actual sessions.
+OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization.
Tool compatibility and adapter contracts
@@ -121,7 +121,7 @@ Hooks are authored with `${CLAUDE_PLUGIN_ROOT}`; the installer translates the ro
| GitHub Copilot | yes | `${PLUGIN_ROOT}` | Declared, never observed running |
| Cursor | declared | `./` | Own hook format: the converter rewrites the root to a plugin-relative path before token substitution. No plugin hook observed firing headless; what registers a plugin in Cursor's plugin directory is unknown |
| OpenCode | no, by a second route | โ | See below |
-Runtime contracts: [hook bridge](src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts), [shared V2 adapter](assets/configs/opencode/opencode-events.js.txt), [telemetry payload adapter](../plugins/aidd-telemetry/hooks/opencode-plugin.js). The shared helper is `.opencode/hooks/opencode-events.js`. [Telemetry coverage](../plugins/aidd-telemetry/README.md#coverage) states supported versions and limitations. Unsupported hooks and skipped installation surfaces must be reported.
+Runtime contracts: [hook bridge](src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts), [shared V2 adapter](assets/configs/opencode/opencode-events.js.txt). The shared helper is `.opencode/hooks/opencode-events.js`. Unsupported hooks and skipped installation surfaces must be reported.
diff --git a/cli/README.md b/cli/README.md
index eca3691d9..ea60e90e5 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -121,20 +121,6 @@ aidd setup --scope user --ai claude,codex,copilot --plugins none --yes
| `aidd auth status` | Show which credential resolves, from where, at which level |
| `aidd auth logout` | Delete the stored credential |
-### Telemetry
-
-Opt-in, off until asked. Record shapes and report axes: [`../plugins/aidd-telemetry/README.md`](../plugins/aidd-telemetry/README.md).
-
-| Command | Does |
-| --- | --- |
-| `aidd telemetry on` | Flip the git-tracked switch on and git-ignore the run journal |
-| `aidd telemetry off` | Flip the switch off, warning when a tool still exports on its own |
-| `aidd telemetry check` | Say whether the chain is actually recording for this project |
-| `aidd telemetry read` | Read session cost from the files the tools already wrote |
-| `aidd telemetry report` | Report a period, or one task in it, along one `--axis` |
-| `aidd telemetry identity` | Attach or drop this person's identifier: `use`, `off`, `link`, `unlink` |
-| `aidd telemetry forget` | Irreversibly drop the journal, the stored records, and the identity file |
-
## Translate
`aidd translate --to --out ` converts a framework source tree into a target-native plugin tree.
@@ -163,12 +149,8 @@ OpenCode and Kilo Code declare no marketplace contract, so they are flat only. E
| `AIDD_TOKEN` | Token used for every fetch, ahead of any stored credential |
| `AIDD_USER_CONFIG_DIR` | Relocates the user config directory outright, credentials included |
| `XDG_CONFIG_HOME` | Names the config root when `AIDD_USER_CONFIG_DIR` is unset |
-| `AIDD_TELEMETRY_DIR` | Names where telemetry records are kept, ahead of the config directory |
-| `AIDD_RUNS_DIR` | Names the run journal directory, read alike by the CLI and the hook |
| `AIDD_SKIP_UPDATE_CHECK` | Set to `1`, skips the self-update check before a command |
-Share `AIDD_TELEMETRY_DIR` to pool figures across a team. Never share `AIDD_USER_CONFIG_DIR`: it also moves `auth.json`.
-
## Where things live
In the project:
@@ -176,11 +158,10 @@ In the project:
| Path | Holds |
| --- | --- |
| `.aidd/manifest.json` | Every owned file and its hash |
-| `.aidd/config.json` | The telemetry switch, git-tracked, kept by `clean` |
+| `.aidd/config.json` | A git-tracked file `clean` never removes |
| `.aidd/auth.json` | The project-level credential |
| `.aidd/marketplaces.json` | Marketplaces registered at project scope |
| `.aidd/cache/`, `.aidd/plugin-cache/` | Fetched catalogs, built trees, plugin sources |
-| `aidd_docs/runs/` | The run journal, at the repository root above the project |
On the machine, under `$AIDD_USER_CONFIG_DIR`, else `$XDG_CONFIG_HOME/aidd`, else `~/.config/aidd`:
@@ -188,9 +169,6 @@ On the machine, under `$AIDD_USER_CONFIG_DIR`, else `$XDG_CONFIG_HOME/aidd`, els
- `marketplaces.json` and `references.json`, the user-scope registry and the projects claiming it.
- `manifest.json`, what `--scope user` owns.
- `cache/built//`, one built tree per CLI version.
-- Telemetry records, unless `AIDD_TELEMETRY_DIR` moves them.
-
-The person identity file stays in the user profile and is never relocated by `AIDD_USER_CONFIG_DIR`.
Per tool, the settings file the CLI writes:
diff --git a/cli/aidd_docs/memory/architecture.md b/cli/aidd_docs/memory/architecture.md
index 8fa139990..091081633 100644
--- a/cli/aidd_docs/memory/architecture.md
+++ b/cli/aidd_docs/memory/architecture.md
@@ -16,22 +16,18 @@ Every edge is a real import direction: the inter-context arrows are exactly `ALL
flowchart LR
Presentation["presentation"] --> Distribution["distribution"]
Presentation --> Framework["framework"]
- Presentation --> Telemetry["telemetry"]
Presentation --> Tools["tools"]
Presentation --> Translate["translate"]
Runtime["runtime"] --> Distribution
Runtime --> Framework
- Runtime --> Telemetry
Runtime --> Tools
Runtime --> Translate
Framework --> Translate
Framework --> Distribution
Framework --> Tools
Translate --> Tools
- Telemetry --> Tools
Distribution --> Kernel["kernel"]
Framework --> Kernel
- Telemetry --> Kernel
Tools --> Kernel
Translate --> Kernel
```
@@ -39,8 +35,6 @@ flowchart LR
## Key decisions
- Bounded contexts, never layers. Enforced by `tests/architecture/`, stated in `.claude/rules/00-architecture/0-contexts.md`.
-- A tool declares, a context reads: measurement vocabulary lives in `kernel/measurement.ts`, so the edge runs `telemetry โ tools` only. `telemetry.md` names what it reuses along it.
-- Telemetry reaches no context but `tools`; what it needs elsewhere is its own port, satisfied at the composition root.
- Some tools' project config is inert: Codex, Copilot and Claude load a plugin only once their own CLI registered it. A per-tool fact, verified against the real tool.
- Claude's registration is driven at `--scope local`, so the hashed file keeps a single writer.
- Two file regimes: what this CLI owns is regenerated; what it co-owns with a person is merged, conflicts reported.
diff --git a/cli/aidd_docs/memory/cli.md b/cli/aidd_docs/memory/cli.md
index cc8b3f563..558ea2e06 100644
--- a/cli/aidd_docs/memory/cli.md
+++ b/cli/aidd_docs/memory/cli.md
@@ -4,14 +4,14 @@ The `aidd` command-line tool: its commands, inputs, and distribution.
## Commands
-Thirty-three leaf commands. Read them live: `aidd --help`, then each group's. `scripts/smoke-tools.sh` exercises every one and fails when its list drifts.
+Thirty leaf commands. Read them live: `aidd --help`, then each group's. `scripts/smoke-tools.sh` exercises every one and fails when its list drifts.
- `setup`, `doctor`, `sync`, `clean`: bring a project to a correct state, keep it there.
- `framework install | update | remove | rules`, chosen by `--tool `.
- `plugin install | list | remove | search | update`.
- `marketplace add | list | remove | refresh | check`.
- `auth login | logout | status`.
-- `telemetry on | off | read | report | check | forget | identity`; `identity` carries `use | off | link | unlink`.
+- `telemetry on | off | forget | ingest | task | report | identity`: local token measurement for Claude Code, mapped in `telemetry.md`.
- `translate `: author-side, converts a source into a target-native plugin tree.
- `update`, aliased `upgrade`: the CLI itself.
@@ -53,4 +53,4 @@ Thirty-three leaf commands. Read them live: `aidd --help`, then each group's. `s
- npm bin `aidd` โ `dist/cli.js`, one ESM bundle plus five JSON schemas read from disk; `files` must ship them.
- `npx @ai-driven-dev/cli@latest`, or global install. Build and publish: `deployment.md`.
-- `AIDD_USER_CONFIG_DIR` relocates `userConfigDir()`; the one list of what moves: `auth.json`; `marketplaces.json` and `cache/built//`; `references.json`; the `--scope user` `manifest.json`; `cache/update-check.json` and the older root `update-check.json` (`runtime/self-update/check-update-use-case.ts`); the telemetry sink root, only as a legacy fallback when `AIDD_TELEMETRY_DIR` is unset (`telemetry.md`). Never `identity.json`, which `resolveAiddConfigDir()` (`kernel/reading/home-dir.ts`) refuses this variable for.
+- `AIDD_USER_CONFIG_DIR` relocates `userConfigDir()`; the one list of what moves: `auth.json`; `marketplaces.json` and `cache/built//`; `references.json`; the `--scope user` `manifest.json`; `cache/update-check.json` and the older root `update-check.json` (`runtime/self-update/check-update-use-case.ts`).
diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md
index d114d432e..6af1d9f42 100644
--- a/cli/aidd_docs/memory/codebase-map.md
+++ b/cli/aidd_docs/memory/codebase-map.md
@@ -35,14 +35,6 @@ src/
โ โ โ โโโ ports/
โ โ โ โโโ recipes/
โ โ โโโ infrastructure/
-โ โโโ telemetry/ # what a session cost and who it was for
-โ โ โโโ application/
-โ โ โโโ domain/
-โ โ โ โโโ formats/
-โ โ โ โโโ ports/
-โ โ โ โโโ report/
-โ โ โ โโโ axes/
-โ โ โโโ infrastructure/
โ โโโ tools/ # what a project targets, and what each target declares
โ โ โโโ domain/
โ โ โ โโโ capabilities/
@@ -58,6 +50,31 @@ src/
โ โ โ โโโ opencode/
โ โ โ โโโ vscode/
โ โ โโโ infrastructure/
+โ โโโ telemetry/ # what each billed model call consumed, read from a tool's own transcripts
+โ โ โโโ application/
+โ โ โ โโโ forget/ # what measurement keeps on a machine, shown and erased
+โ โ โ โโโ identity/ # who, if anyone, the measurement names
+โ โ โ โโโ report/ # what the stored calls consumed in a period, along one axis
+โ โ โ โโโ switch/ # opting a repository in or out, and leaving it clean of the previous version
+โ โ โโโ domain/
+โ โ โ โโโ declaration/ # the task a person declares, and whether work is bound to one
+โ โ โ โโโ formats/
+โ โ โ โโโ consent/ # a clone's identity, and when it consented
+โ โ โ โโโ identity/ # the identifier a person chose to be named by
+โ โ โ โโโ legacy/ # what the previous version wrote, by name and location
+โ โ โ โโโ ports/
+โ โ โ โ โโโ bindings/ # the declarations kept beside the ledger, and the lock their writers share
+โ โ โ โ โโโ forget/
+โ โ โ โ โโโ identity/
+โ โ โ โ โโโ switch/
+โ โ โ โโโ report/ # attribution of a call, the days of a report, the axes it splits by
+โ โ โ โโโ switch/ # the previous version's config block, Claude's retention
+โ โ โโโ infrastructure/
+โ โ โโโ consent/ # reading a clone's identity from the file system, and the consent history file
+โ โ โโโ declaration/ # where a declaration is kept: git config and the telemetry dir
+โ โ โโโ forget/ # erasing the telemetry dir, the previous version's files, git config keys
+โ โ โโโ identity/ # the identity file in the telemetry dir
+โ โ โโโ switch/ # the project config, what an earlier measurement left in a repository, and Claude settings; the clone's consent in git config is `git-consent-adapter.ts`, beside this folder
โ โโโ translate/ # canonical source to target-native content
โ โโโ application/
โ โ โโโ strategies/
@@ -71,6 +88,7 @@ src/
โโโ presentation/ # everything that talks to a human โ depends on contexts, never the reverse
โ โโโ commands/ # one file per command, wiring only
โ โโโ display/ # rendering a result
+โ โ โโโ telemetry/ # the telemetry report, which outgrew one file beside the others
โ โโโ prompts/ # asking the user; the decision stays in the context
โโโ runtime/ # technical services that are not a context
โโโ assets/
@@ -113,4 +131,3 @@ src/
| a port used by one context | that context's `domain/ports/`, adapter in its `infrastructure/` |
| a port used by two | `kernel/ports/`, adapter in `runtime/` |
| a runtime service | `runtime//`, wired from `runtime/wiring/` |
-| a cost-report axis (its own key, sentinels, group shape, order) | `contexts/telemetry/domain/report/axes/`; the pass that fills it stays in `cost-report.ts`, which the axis reaches only through `import type` |
diff --git a/cli/aidd_docs/memory/deployment.md b/cli/aidd_docs/memory/deployment.md
index a9d9f1469..47adfef2c 100644
--- a/cli/aidd_docs/memory/deployment.md
+++ b/cli/aidd_docs/memory/deployment.md
@@ -4,7 +4,7 @@ Where the package ships and how: CI, release, and the environment it reads.
## Pipeline
-- `.github/workflows/cli-ci.yml` always triggers โ no `on.paths` filter, so its required check stays satisfiable on every PR. Its own `changes` job decides relevance by `git diff`-ing the base against the head in bash: `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, this workflow file itself, or `plugins/aidd-telemetry/**` excluding its own prose. Every other job โ `cli-typecheck`, `cli-lint`, `cli-architecture`, `cli-coverage`, `cli-smoke`, `cli-build`, `cli-knip`, `identifier-join` (the session-identifier probe, then Claude Code's own `plugin validate` over a fresh claude build through `scripts/check-claude-accepts-build.cjs`), `cli-jscpd`, `kanban-checks`, `windows` โ runs only when `changes` says relevant; `cli-mutation` is a matrix over the scopes `changes` names through `scripts/mutation-scopes-to-run.mjs`, each restoring the newest incremental file its branch or base saved, and `gate` (check name `cli / gate`) fans all of them in. `gate` is the one check the branch rulesets require (`.github/rulesets/main.json`, `next.json`).
+- `.github/workflows/cli-ci.yml` always triggers โ no `on.paths` filter, so its required check stays satisfiable on every PR. Its own `changes` job decides relevance by `git diff`-ing the base against the head in bash: `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, this workflow file itself. Every other job โ `cli-typecheck`, `cli-lint`, `cli-architecture`, `cli-coverage`, `cli-smoke`, `cli-build`, `cli-knip`, `identifier-join` (the session-identifier probe, then Claude Code's own `plugin validate` over a fresh claude build through `scripts/check-claude-accepts-build.cjs`), `cli-jscpd`, `kanban-checks`, `windows` โ runs only when `changes` says relevant; `cli-mutation` is a matrix over the scopes `changes` names through `scripts/mutation-scopes-to-run.mjs`, each restoring the newest incremental file its branch or base saved, and `gate` (check name `cli / gate`) fans all of them in. `gate` is the one check the branch rulesets require (`.github/rulesets/main.json`, `next.json`).
- `.github/workflows/ci.yml` โ commitlint, release-please, then the release jobs. It runs none of the checks above.
- Build: `pnpm build` (tsup) โ `dist/cli.js`, plus the five JSON schemas its `onSuccess` copies beside it. They are read from disk at runtime; dropping them breaks the binary.
- Bundle budget in `package.json` (`bundleBudgetKB`), enforced by `scripts/check-bundle-size.mjs` after every build. That script's own header comment is the registry of every raise and reset, with the measurement behind each โ read it there rather than a count here, which goes stale the next time the budget moves.
diff --git a/cli/aidd_docs/memory/internal/smoke-real.md b/cli/aidd_docs/memory/internal/smoke-real.md
index f6c12a2b9..af3b5d8e7 100644
--- a/cli/aidd_docs/memory/internal/smoke-real.md
+++ b/cli/aidd_docs/memory/internal/smoke-real.md
@@ -28,7 +28,7 @@
Then each host's own inventory: `claude plugin details` lists the fixture's skills, hook events and MCP servers (never its agents: Claude counts none, measured 2026-09-09), `codex plugin list` marks it installed and enabled, `copilot plugin list` enabled. Cursor and opencode expose no inventory command.
3. `doctor`.
4. A host-side `claude plugin uninstall --scope local`, then the `sync --force` that repairs it.
-5. Opencode's bridge, then a real `opencode run` under the repository's own `aidd-telemetry`: its run journal must hold a `session_start` from opencode. The bridge check alone passed while v5.10.0 journalled nothing (#812).
+5. Opencode's bridge.
6. Two `marketplace add` guards: a different catalog under one name, an alias diverging from the catalog's.
7. `setup --scope user`: project clean per `git status --porcelain`, `userConfigDir()/manifest.json` appears, `doctor --scope user` healthy.
8. Two projects sharing one machine-scope marketplace.
diff --git a/cli/aidd_docs/memory/project-brief.md b/cli/aidd_docs/memory/project-brief.md
index 8f6238372..df0b82749 100644
--- a/cli/aidd_docs/memory/project-brief.md
+++ b/cli/aidd_docs/memory/project-brief.md
@@ -25,13 +25,12 @@ What this package is, the problem it solves, and its domain language.
| Marketplace | where plugins come from, registered per project or per machine |
| Context | a bounded area of this codebase; the unit that owns a concern |
| Capability | what a tool declares it can host โ hooks, mcp, plugins, settings |
-| Record | one measured figure for one session, stored per machine |
-| Run journal | what a session did, written into the project by a hook |
-| Attribution | how strongly a figure is tied to a person, a task or a step |
+| Usage record | one billed model call, counted once and stored on this machine |
+| Declaration | the task and ticket a person states for their work; the only source of attribution |
## Key features
- Install, update and remove the framework for a tool, and repair what drifted.
- Install plugins from a marketplace, driving a tool's own CLI where its project files are inert.
- Translate an arbitrary source into a target-native plugin tree, recording nothing.
-- Measure what sessions cost, from local files, with no service and no upload.
+- Count what Claude Code sessions consume, from local files, with no service and no upload.
diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md
index 2f7ca1240..d6f108e03 100644
--- a/cli/aidd_docs/memory/telemetry.md
+++ b/cli/aidd_docs/memory/telemetry.md
@@ -1,39 +1,68 @@
# Telemetry
-What a session cost, measured from files each AI tool already wrote, stored per machine. No process runs to produce it; nothing leaves the machine. Boundary for a hosted destination: [`measurement-may-reach-a-hosted-destination.md`](../../../aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md).
+> For: contributors. A map to the code. Formats, rules and stores: [usage contract](../../../aidd_docs/product/usage-contract.md). Usage: [plugin README](../../../plugins/aidd-telemetry/README.md).
-## Sink
+## Adding a tool
-- `TelemetrySinkAdapter` (`src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts`) resolves its root: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR` (legacy, never the variable a team shares), else the default config dir (`.config/aidd` on POSIX and on Windows with pre-existing data, `%APPDATA%/aidd` otherwise).
-- One `.jsonl` day file per UTC day under `telemetry/`, append-only, `0600`/`icacls` unless the directory was user-named.
-- Two record kinds (`TelemetrySinkRecordKind`, `contexts/telemetry/domain/telemetry-sink-record.ts`): `request`, `session`. Provenance: `export` or `local-read`.
+| Edit | Where |
+| --- | --- |
+| its line format | `contexts/telemetry/domain/formats/` |
+| its transcript source | `contexts/telemetry/infrastructure/` |
+| one reader in the ingest list | `runtime/wiring/telemetry.ts` |
+| its name | `USAGE_TOOLS` in `domain/usage-record.ts` |
-## Run journal
+Storage, attribution and the report need nothing. Session declarations come from Claude's hooks only (release 1): another tool's calls are attributed by branch.
-- Written by the plugin hook, not the CLI: `plugins/aidd-telemetry/hooks/journal.cjs` reads stdin, detects the host, dispatches to `hooks/lib/`.
-- `record.cjs` mints a run id, appends `session_start`/`turn_end`; `step-starts.cjs`, `step-ends.cjs`, `task-declared.cjs`, `file-writes.cjs` append the rest; `repo.cjs` resolves paths and tightens permissions; `trailer-repair.cjs` backs the commit trailer; `opencode-plugin.js` is OpenCode's own entry.
-- Lives at the git root above the project: `kernel/paths.ts`'s `resolvedRunsDir` walks up via `repositoryRootAbove`. `AIDD_RUNS_DIR` overrides, read alike by `hooks/lib/repo.cjs` and the CLI.
+## Data flow
-## Report
+```mermaid
+flowchart LR
+ Files[("each tool's session files")] -->|read from the saved offset| Reader["read: one reader per tool"]
+ Reader --> Resolver["resolve: cwd to repository and consent"]
+ Resolver -->|upsert, one record per call| Ledger[("ledger/")]
+ Hooks["plugin hooks"] -->|write| Bindings[("bindings/ and git config")]
+ Task["telemetry task"] -->|write| Bindings
+ Ledger --> Report["report: attribute, then split by one axis"]
+ Bindings --> Report
+ Report --> Out["text or the version 1 envelope"]
+```
-- `aidd telemetry report` renders the axes in `ARTEFACT_AXES` (`src/presentation/display/cost-report-artefact.ts`).
-- `--axis ` prints one markdown table; `--json` the envelope. Filters: `--from`, `--to`, `--days`, `--task`, `--project`, `--step`, `--model`, `--tool`.
-- Envelope version `cost_report_version` (`COST_REPORT_ENVELOPE_VERSION`, `contexts/telemetry/domain/cost-report-envelope.ts`) bumps when a consumer must tell shapes apart, not per field.
+- A command that reports or ingests reads new lines first, under the ledger lock. `ingest` is the same step on its own, run quietly by the plugin at session start.
+- Reading is one pass: tool-specific only up to the usage record. Resolving, storing, attributing and reporting are written once for every tool.
+- The hooks and the CLI never call each other's code. They meet in the files, whose formats one fixture pins on both sides.
-## Attribution
+## Layers
-- Person (`contexts/telemetry/domain/person-resolution.ts`): `mapped`, `unresolved`, `this-machine`. Identity is read and written from this machine's profile only; `aidd telemetry identity` never reads `AIDD_USER_CONFIG_DIR` or `.aidd/config.json`.
-- Task, step, flow (`task-attribution.ts`, `step-attribution.ts`, `flow-attribution.ts`): declared-vs-inferred over the journal's closed intervals (`journal-intervals.ts`).
-- Agent: `TelemetryRouteSupply.agentName` (`kernel/measurement.ts`); only Claude Code's reader sets it (`isSidechain`/`attributionAgent`, `contexts/telemetry/domain/formats/claude-code-transcript.ts`).
+| Layer | Path under `cli/src/` | Holds |
+| --- | --- | --- |
+| Presentation | `presentation/commands/telemetry.ts`, `presentation/display/telemetry*` | the command group: `on`, `off`, `forget`, `ingest`, `task`, `status`, `report`, `identity` |
+| Composition | `runtime/wiring/telemetry.ts` | the telemetry directory, the environment variables, every use case wired |
+| Application | `contexts/telemetry/application/` | one use case per command, the directory resolver, `report/` |
+| Domain | `contexts/telemetry/domain/` | the usage record and its fold, the declaration and attribution rules, consent, the report axes |
+| Infrastructure | `contexts/telemetry/infrastructure/` | the ledger, locks, git config and file stores, the transcript source |
-## What a tool declares
+## Where to look
-- `kernel/measurement.ts`'s `TelemetryLocalRead`: `declared` carries `TelemetryRouteSupply` (`tokenCounters`, `amount`, `toolStatedStep`, `agentName`), an optional `TranscriptLocation`, an optional `limitation`; `unsupported` carries a `reason`.
-- `telemetry โ tools` is the one allowed edge. Along it `telemetry` reuses seven `tools` public modules, listed here only: `registry.ts`, `marketplace-settings.ts`, `host-plugin-registration.ts`, `ports/host-plugin-registry-reader.ts`, and the `plugin-root-token`/`flat-hooks-merge`/`cursor-hooks-project-merge` format helpers. `domain/telemetry-setup.ts` crosses too.
-- Declared: `claude`, `codex`, `copilot`, `opencode`, each in `contexts/tools/domain/profiles//profile.ts`. `unsupported`: `cursor`. Silent: `vscode`.
+| Question | File |
+| --- | --- |
+| What a Claude Code line becomes | `domain/formats/claude-transcript-usage.ts` |
+| Why a call is counted once | `domain/usage-fold.ts` |
+| Where a call's repository comes from | `application/directory-resolver.ts`, `domain/repository-identity.ts` |
+| Which task a call belongs to | `domain/report/attribution.ts`, `domain/declaration/binding-resolution.ts` |
+| What counts as consent | `domain/telemetry-consent.ts`, `infrastructure/git-consent-adapter.ts` |
+| Whether a call is stored | `application/directory-resolver.ts` |
+| What a clone is | `domain/consent/clone-identity.ts` |
+| When a clone consented | `domain/consent/consent-history.ts`, `application/consent-log.ts` |
+| Which transcripts are read | `application/ingest-usage-use-case.ts`, `application/read-usage-use-case.ts` |
+| What `on` and `forget` clean up from an earlier measurement | `domain/legacy/`, `application/switch/`, `application/forget/` |
+| The report axes and the envelope | `domain/report/usage-report.ts`, `application/report/report-envelope.ts` |
-## Gotchas
+## The plugin side
-- `AIDD_RUNS_DIR` answers where the journal lives; `AIDD_TELEMETRY_DIR`/`AIDD_USER_CONFIG_DIR` where the figures do. `cli.md` lists what else the latter moves. Read `plugins/aidd-telemetry/README.md` ("Share `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`") before pointing anyone at either.
-- A relocated `HOME` does not relocate a real `codex` if `CODEX_HOME` is set (`testing.md`).
-- A generated `prepare-commit-msg` (lefthook, husky) never calls the delegate until the printed job is added by hand; `on` and `check` name it, neither edits those files.
+`plugins/aidd-telemetry/hooks/` is plain Node and imports nothing from here. It asks for a task, answers a typed declaration by running `aidd telemetry task`, and records which session a process is on. Its reading of the stores is pinned against `scripts/__tests__/fixtures/telemetry-bindings/`, read by `tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts` and by the hook tests.
+
+## Tests
+
+- Unit and integration tests mirror `src/` under `tests/contexts/telemetry/`.
+- The `telemetry` end-to-end tests under `tests/e2e/` run the built binary; `tests/e2e/telemetry-journey.e2e.test.ts` drives the plugin's real hooks through a shim to it and checks that every axis adds up to the total.
+- The skills' commands are checked against the help golden by `scripts/__tests__/telemetry-skills-name-real-commands.test.js`.
diff --git a/cli/aidd_docs/memory/testing.md b/cli/aidd_docs/memory/testing.md
index 971ef7106..c7c361a32 100644
--- a/cli/aidd_docs/memory/testing.md
+++ b/cli/aidd_docs/memory/testing.md
@@ -24,7 +24,7 @@ How this package is tested: the layers, the tools, the conventions.
- A temp directory: `await mkdtemp(join(tmpdir(), "aidd--"))`, never a fixed name (a plantable symlink). The prefix is what `scripts/sweep-stale-test-dirs.cjs` reclaims.
- A test name is a phrase of observable behaviour; nested `describe` reads as a sentence. `E2E:` is a recurring legacy prefix, not one to copy.
- A golden snapshot is machine-independent, never derived from an absolute path: [`golden-machine-independence.md`](../../.claude/skills/test/references/golden-machine-independence.md).
-- `describe.concurrent` appears in no unit or integration file; in e2e the golden and command-matrix suites use it, most `telemetry-*` suites do not. An observed split, not a rule.
+- `describe.concurrent` appears in no unit or integration file; in e2e the golden and command-matrix suites use it. An observed split, not a rule.
- A bug fix's review reproduces the user's scenario against the built binary: [`bug-empirical-reproduction.md`](../../.claude/skills/test/references/bug-empirical-reproduction.md).
- A sandboxed run reaches no tool binary and no real profile: `tests/e2e/helpers.ts` narrows `PATH`, relocates `HOME`/`XDG_CONFIG_HOME`/`USERPROFILE`/`APPDATA`; `sandbox-reaches-no-tool-binary.e2e.test.ts` holds it. `CODEX_HOME` is never overridden: a machine setting it points a real `codex` at its own profile regardless.
- Records land under `AppData\Roaming` on Windows, `.config` elsewhere. Assert through the helper.
diff --git a/cli/aidd_docs/memory/vcs.md b/cli/aidd_docs/memory/vcs.md
index a2fa33ecf..88b11bece 100644
--- a/cli/aidd_docs/memory/vcs.md
+++ b/cli/aidd_docs/memory/vcs.md
@@ -14,7 +14,6 @@ What version control means for this package. The repository's own conventions
- `cli` is this package's only scope in `commitlint.config.cjs`. `domain`, `infra` and `install` are not in the enum and warn.
- A `feat` or `fix` under `cli/` releases `@ai-driven-dev/cli` alone, under its own `cli-v` tag.
-- A commit made by an AI session carries an `AIDD-Session-Id` trailer, appended by a `prepare-commit-msg` line that `aidd telemetry on` installs โ except where lefthook or husky owns that hook and regenerates it, in which case `telemetry on` installs the delegate and prints the job to add by hand; this repository's own `lefthook.yml` carries that job. `telemetry off` removes what it installed. It is what lets a session's cost be read per commit.
- The enforced header limit is 100, whatever a page says โ the repository-root `commitlint.config.cjs`, which overrides nothing and so takes `config-conventional`'s default. That is the one the hook runs: `lefthook.yml`'s `commit-msg` job is `pnpm exec commitlint --edit {1}` from the repository root, and `ci.yml` passes the same file as `configFile`. The package-local `cli/commitlint.config.cjs` sets `header-max-length` to 120 and is not what either of them reads.
## Commit Strategy
diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/plan.md
index 652c0da4e..44ff1e644 100644
--- a/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/plan.md
+++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/plan.md
@@ -1,6 +1,6 @@
---
objective: "La tรฉlรฉmรฉtrie de next vit dans la nouvelle architecture, et rien de ce qui marchait des deux cรดtรฉs ne s'arrรชte."
-status: in-progress
+status: superseded
---
# Plan : intรฉgrer la tรฉlรฉmรฉtrie sans rรฉgression
diff --git a/cli/biome.json b/cli/biome.json
index 9a30b4840..024b161b1 100644
--- a/cli/biome.json
+++ b/cli/biome.json
@@ -470,8 +470,13 @@
"message": "domain must not import application, infrastructure, presentation or runtime"
},
{
- "group": ["**/framework/**", "**/translate/**", "**/distribution/**"],
- "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts"
+ "group": [
+ "**/framework/**",
+ "**/translate/**",
+ "**/distribution/**",
+ "**/tools/**"
+ ],
+ "message": "telemetry imports only the kernel \u2014 see context-graph.arch.test.ts"
}
]
}
@@ -498,10 +503,11 @@
"**/framework/**",
"**/translate/**",
"**/distribution/**",
+ "**/tools/**",
"**/presentation/**",
"**/runtime/**"
],
- "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts"
+ "message": "telemetry imports only the kernel \u2014 see context-graph.arch.test.ts"
}
]
}
@@ -524,10 +530,11 @@
"**/framework/**",
"**/translate/**",
"**/distribution/**",
+ "**/tools/**",
"**/presentation/**",
"**/runtime/**"
],
- "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts"
+ "message": "telemetry imports only the kernel \u2014 see context-graph.arch.test.ts"
}
]
}
diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json
index fae55210b..502606351 100644
--- a/cli/mutation-scopes.json
+++ b/cli/mutation-scopes.json
@@ -20,8 +20,8 @@
"shards": 4
},
"telemetry": {
- "mutate": "src/contexts/telemetry/**/*.ts",
- "break": 93,
+ "mutate": ["src/contexts/telemetry/**/*.ts", "src/presentation/display/telemetry/**/*.ts"],
+ "break": 96,
"shards": 3
},
"translate": {
@@ -39,7 +39,7 @@
"shards": 4
},
"presentation": {
- "mutate": "src/presentation/**/*.ts",
+ "mutate": ["src/presentation/**/*.ts", "!src/presentation/display/telemetry/**/*.ts"],
"break": 95
},
"runtime": {
diff --git a/cli/package.json b/cli/package.json
index dac00b389..a9e421de4 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -36,7 +36,7 @@
"node": ">=22.12"
},
"packageManager": "pnpm@12.3.4",
- "bundleBudgetKB": 756,
+ "bundleBudgetKB": 695,
"scripts": {
"build": "tsup && node scripts/check-bundle-size.mjs",
"build:check-size": "node scripts/check-bundle-size.mjs",
diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs
index 2823fb54e..0502ca56e 100644
--- a/cli/scripts/check-bundle-size.mjs
+++ b/cli/scripts/check-bundle-size.mjs
@@ -30,10 +30,19 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
// guards for #829 (+10.4 KB over the preceding 703.8 KB); 1.1% measured headroom.
// 734 KB: 725.8 KB, Kilo Code's profile, generated bridge, and runtime smoke support (+9.6 KB
// over next's 716.2 KB); 1.1% measured headroom.
+// 653 KB: 639.8 KB, the deterministic token measurement replacing the previous telemetry
+// (-86.0 KB under the preceding 725.8 KB); 2.1% measured headroom.
+// 667 KB: 653.3 KB, consent as intervals keyed by a clone and a token, closed by ingest and the
+// hooks, and measurement starting at `on` (+13.5 KB over the 639.8 KB above, reached across the
+// consent rounds; 651.5 KB at the last one); 2.1% measured headroom.
// 751 KB: 750.2 KB, recipe validation moved from Cook's script to the framework CLI after merging next;
// 0.1% measured headroom, with no added runtime dependency.
+// 689 KB: 675.2 KB, that recipe validation merged into the deterministic token measurement
+// (+21.9 KB over the 653.3 KB above); 2.0% measured headroom.
// 756 KB: 755.6 KB, patched esbuild 0.28.2 emits TypeScript parameter-property fields
// correctly (+5,008 bytes over 0.27.3); unchanged application and minification settings.
+// 695 KB: 680.6 KB, that esbuild merged into the deterministic token measurement (+5.3 KB over
+// the 675.3 KB above); 2.1% measured headroom.
const budgetKB = pkg.bundleBudgetKB ?? 500;
const budgetBytes = budgetKB * 1024;
diff --git a/cli/scripts/smoke-real.sh b/cli/scripts/smoke-real.sh
index 22d718f66..51ff408b9 100644
--- a/cli/scripts/smoke-real.sh
+++ b/cli/scripts/smoke-real.sh
@@ -615,46 +615,6 @@ else
skip "opencode bridge check (opencode not installed)"
fi
-section "opencode: a real session under aidd-telemetry writes a journal line"
-# The bridge check above installs the smoke fixture, which journals nothing, so it passed while
-# every OpenCode session recorded nothing (#812). This installs the repository's own
-# aidd-telemetry on the layout a user gets, and asks the journal itself.
-if [[ -n "${PRESENT[opencode]:-}" ]]; then
- PROJ_T=$(mktemp -d "$TMPROOT/proj-telemetry.XXXXXX"); (cd "$PROJ_T" && git init -q)
- PROJECTS+=("$PROJ_T")
- MKT_T="$MKT-telemetry"
- run "setup (opencode, files only)" 0 "" "$PROJ_T" -- \
- node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai opencode \
- --no-default-marketplace --plugins none --yes
- run "marketplace add $MKT_T (this repository)" 0 "" "$PROJ_T" -- \
- node "$CLI" marketplace add "$MKT_T" "$ROOT/.." --scope project --yes
- run "plugin install aidd-telemetry -> opencode" 0 "" "$PROJ_T" -- \
- node "$CLI" plugin install aidd-telemetry --tool opencode --from "$MKT_T" --yes
- run "telemetry on" 0 "" "$PROJ_T" -- node "$CLI" telemetry on --yes
-
- tel_out=$(mktemp)
- ( cd "$PROJ_T" && exec perl -e 'alarm shift; exec @ARGV' 90 opencode run "say ok" ) "$tel_out" 2>&1
- tel_rc=$?
- cat "$tel_out" >> "$LOGFILE"
- # The session opens on its first call, so only a turn that completed can prove an empty
- # journal: exit 0 with no line is #812 itself, a model that never answered proves nothing.
- if grep -qsE '"type":"session_start".*"tool":"opencode"' "$PROJ_T"/aidd_docs/runs/*.jsonl; then
- ok "opencode: the run journal holds a session_start line from opencode"
- elif [[ "$tel_rc" -eq 0 ]]; then
- bad "opencode: the turn completed and the run journal holds no session_start line" \
- "$(ls -la "$PROJ_T/aidd_docs/runs" 2>&1; cat "$tel_out")"
- elif [[ "$tel_rc" -eq 142 ]]; then
- skip "opencode journal: the model never answered within 90s, so the journal proves nothing"
- elif grep -qiE "auth|api key|provider|not logged in|credential" "$tel_out"; then
- skip "opencode journal: needs provider auth on this machine โ exit $tel_rc"
- else
- bad "opencode: exit $tel_rc and the run journal holds no session_start line" "$(cat "$tel_out")"
- fi
- rm -f "$tel_out"
-else
- skip "opencode journal check (opencode not installed)"
-fi
-
# --- Phase C1: the guard refuses a genuinely different catalog under the same name ---
# Identity is a catalog's declared name plus its plugin set, never a path and never a version
# (`marketplace-source-conflict.ts`), so this fixture keeps the name `$MKT` and drops its one
diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh
index c31b400fd..d0a7d5f8b 100755
--- a/cli/scripts/smoke-tools.sh
+++ b/cli/scripts/smoke-tools.sh
@@ -9,7 +9,7 @@
# cannot reach.
#
# `ALL_COMMANDS` below is compared against what `derived_leaves` reads live off the binary,
-# `telemetry identity`'s four verbs counted as leaves of their own.
+# nothing counted as a leaf of its own beyond a parent and its verb.
set -uo pipefail
@@ -28,9 +28,8 @@ ALL_COMMANDS=(
"plugin remove" "plugin list" "plugin install" "plugin search" "plugin update"
"marketplace add" "marketplace list" "marketplace remove" "marketplace refresh" "marketplace check"
"auth login" "auth logout" "auth status"
- "telemetry on" "telemetry off" "telemetry read" "telemetry report" "telemetry check"
- "telemetry forget"
- "telemetry identity use" "telemetry identity off" "telemetry identity link" "telemetry identity unlink"
+ "telemetry on" "telemetry off" "telemetry forget" "telemetry ingest"
+ "telemetry task" "telemetry status" "telemetry report" "telemetry identity"
)
PASS=0; FAIL=0; SKIP=0
@@ -67,7 +66,7 @@ project_tree_hash() {
PARENTS=" plugin marketplace auth framework telemetry "
# A parent one level deeper than PARENTS, so a covered key needs three words there, not two.
-GRANDPARENTS=" telemetry identity "
+GRANDPARENTS=" "
derive_key() {
local first="$1" second="${2:-}" third="${3:-}"
if [[ "$GRANDPARENTS" == *" $first $second "* ]]; then
@@ -202,6 +201,30 @@ run "auth logout" 0 "" "$P_AUTH" -- auth logout
run "auth login --gh (no credentials)" "0|1" "" "$P_AUTH" -- auth login --gh --level project
+# Everything lands under the sandboxed $HOME and $AIDD_USER_CONFIG_DIR set above, so the real
+# profile's telemetry directory is never read or written. A declaration needs a commit and a
+# working branch to bind.
+section "telemetry (sandboxed profile)"
+# Pinned to the sandbox, as `sandboxedEnv` does for the e2e suites: a variable exported in the
+# caller's shell must not point these runs at a real Claude profile or telemetry directory.
+export CLAUDE_CONFIG_DIR="$HOME/.claude" AIDD_TELEMETRY_DIR="$AIDD_USER_CONFIG_DIR/telemetry"
+unset AIDD_TELEMETRY CLAUDE_CODE_SESSION_ID
+P_TEL=$(new_project)
+(cd "$P_TEL" && git -c user.name=t -c user.email=t@t -c commit.gpgsign=false commit -q --allow-empty -m first \
+ && git switch -q -c feat/smoke)
+run "telemetry on" 0 "Measurement is on" "$P_TEL" -- telemetry on --yes
+run "telemetry task" 0 "Declared task" "$P_TEL" -- telemetry task smoke-task --ticket SMOKE-1
+run "telemetry status" 0 "measured since" "$P_TEL" -- telemetry status
+run "telemetry ingest" 0 "" "$P_TEL" -- telemetry ingest
+run "telemetry report" 0 "" "$P_TEL" -- telemetry report --days 1
+run "telemetry identity" 0 "" "$P_TEL" -- telemetry identity smoke-person
+run "telemetry identity --off" 0 "" "$P_TEL" -- telemetry identity --off
+# `off` comes before `forget --yes`: forget unsets the key itself, so an `off` after it has nothing to switch.
+run "telemetry off" 0 "Measurement is off" "$P_TEL" -- telemetry off
+run "telemetry forget (preview)" 0 "Nothing was removed" "$P_TEL" -- telemetry forget
+run "telemetry forget --yes" 0 "Removed" "$P_TEL" -- telemetry forget --yes
+run "telemetry off (after forget)" 0 "Measurement was not on" "$P_TEL" -- telemetry off
+
section "update --check"
out=$(cd "$ROOT" && node "$CLI" update --check 2>&1); rc=$?
if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "update"; ok "update --check (exit $rc)"; else bad "update crashed (exit $rc)" "$out"; fi
@@ -490,56 +513,6 @@ if true; then
&& ok ".claude/settings.local.json removed after clean" \
|| bad ".claude/settings.local.json survived clean"
- # โโ telemetry โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- # HOME and AIDD_USER_CONFIG_DIR are both under TMPROOT, so `forget` deletes a sandbox and
- # never a person's own profile.
- section "telemetry"
- P_TEL=$(new_project)
- run "telemetry check (before anything)" "0|1" "" "$P_TEL" -- telemetry check
- run "telemetry on --yes" 0 "" "$P_TEL" -- telemetry on --yes
- [[ -f "$P_TEL/.aidd/config.json" ]] && ok "telemetry on writes the switch" || bad "no .aidd/config.json after telemetry on"
- run "telemetry identity use" 0 "" "$P_TEL" -- telemetry identity use
- run "telemetry identity off" 0 "" "$P_TEL" -- telemetry identity off
- # link/unlink need a real identifier, so `--help` pins the leaf without mutating a profile.
- run "telemetry identity link --help" 0 "" "$P_TEL" -- telemetry identity link --help
- run "telemetry identity unlink --help" 0 "" "$P_TEL" -- telemetry identity unlink --help
- run "telemetry read" 0 "" "$P_TEL" -- telemetry read
- run "telemetry report" 0 "" "$P_TEL" -- telemetry report
- run "telemetry off" 0 "" "$P_TEL" -- telemetry off
- run "telemetry forget --yes" 0 "" "$P_TEL" -- telemetry forget --yes
-
- # A lefthook-owned repository regenerates prepare-commit-msg on every install, wiping any
- # line `telemetry on` appends, so it must print the job to add by hand instead of promising
- # a trailer. Detection reads a root marker file, never a real lefthook binary.
- P_TEL_LEFTHOOK=$(new_project)
- cat > "$P_TEL_LEFTHOOK/lefthook.yml" <<'LEFTHOOK_YML'
-pre-commit:
- commands:
- example:
- run: echo hi
-LEFTHOOK_YML
- run "telemetry on --yes (lefthook-owned hook)" 0 "prepare-commit-msg:" "$P_TEL_LEFTHOOK" -- telemetry on --yes
-
- # Once a manager owns prepare-commit-msg, `on` writes the delegate to the common git dir and
- # ignores core.hooksPath, which husky routes under `.husky/`; `off` must resolve the same way
- # or it finds nothing to delete under exactly this divergence.
- P_TEL_HUSKY=$(new_project)
- mkdir -p "$P_TEL_HUSKY/.husky"
- cat > "$P_TEL_HUSKY/.husky/prepare-commit-msg" <<'HUSKY_HOOK'
-#!/bin/sh
-echo husky-owned
-HUSKY_HOOK
- chmod +x "$P_TEL_HUSKY/.husky/prepare-commit-msg"
- (cd "$P_TEL_HUSKY" && git config core.hooksPath .husky)
- run "telemetry on --yes (husky core.hooksPath)" 0 "husky" "$P_TEL_HUSKY" -- telemetry on --yes
- [[ -f "$P_TEL_HUSKY/.git/hooks/aidd-session-trailer.sh" ]] \
- && ok "telemetry on writes the delegate to the common hooks dir under husky" \
- || bad "delegate missing from .git/hooks under husky's core.hooksPath"
- run "telemetry off (husky core.hooksPath)" 0 "" "$P_TEL_HUSKY" -- telemetry off
- [[ ! -f "$P_TEL_HUSKY/.git/hooks/aidd-session-trailer.sh" ]] \
- && ok "telemetry off removes the delegate even when core.hooksPath diverges" \
- || bad "delegate survived telemetry off under husky's core.hooksPath"
-
fi
# โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index bcebebfc4..945e7a7d1 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -54,6 +54,8 @@ const ONLINE_COMMAND_PATHS = new Set([
program.hook("preAction", async (_thisCommand, actionCommand) => {
if (process.env.AIDD_SKIP_UPDATE_CHECK === "1") return;
+ // A hook runs `telemetry ingest --quiet`; an update notice would break its silence.
+ if (resolveCommandPath(actionCommand).startsWith("telemetry")) return;
const opts = program.opts<{ verbose?: boolean }>();
const output = new CLIOutput(opts.verbose ?? false);
const deps = await createDeps(process.cwd(), { verbose: opts.verbose ?? false }, output).catch(
diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts
index f9c53fd2d..237c32067 100644
--- a/cli/src/contexts/framework/application/clean-use-case.ts
+++ b/cli/src/contexts/framework/application/clean-use-case.ts
@@ -270,7 +270,7 @@ export class CleanUseCase {
}
}
- // `config.json` is the committed telemetry switch: a file clean did not write, so clean never
+ // `config.json` is a committed file clean did not write, so clean never
// removes it. Everything AIDD did write must go before the emptiness check, or its own presence
// blocks a removal that should happen โ the registry `marketplace add` writes included.
private async removeAiddState(projectRoot: string): Promise {
diff --git a/cli/src/contexts/framework/application/gitignore-use-case.ts b/cli/src/contexts/framework/application/gitignore-use-case.ts
index f7c2ff42e..dfbec0302 100644
--- a/cli/src/contexts/framework/application/gitignore-use-case.ts
+++ b/cli/src/contexts/framework/application/gitignore-use-case.ts
@@ -7,8 +7,7 @@ export class GitignoreUseCase {
constructor(private readonly fs: FileReader & FileWriter) {}
/** `true` when at least one of `entries` was newly appended, `false` when every one was already
- * there. One caller depends on it: `aidd telemetry on` announces the journal being ignored only
- * on the run that actually adds the line. */
+ * there. */
async execute(projectRoot: string, entries: string[]): Promise {
const gitignorePath = `${projectRoot}/${GITIGNORE_FILENAME}`;
diff --git a/cli/src/contexts/framework/application/install/post-install-pipeline-use-case.ts b/cli/src/contexts/framework/application/install/post-install-pipeline-use-case.ts
index 34802e557..b45a4b2bb 100644
--- a/cli/src/contexts/framework/application/install/post-install-pipeline-use-case.ts
+++ b/cli/src/contexts/framework/application/install/post-install-pipeline-use-case.ts
@@ -17,9 +17,8 @@ export class PostInstallPipelineUseCase {
async execute(options: PostInstallPipelineOptions): Promise {
const { projectRoot, manifest } = options;
await this.manifestRepo.save(manifest);
- // One call for everything this CLI's own writes require ignored: the plugin cache, the run
- // journal (which belongs to the repository it describes, never to a commit), and each installed
- // tool's machine-local file.
+ // One call for everything this CLI's own writes require ignored: the plugin cache and each
+ // installed tool's machine-local file.
await this.gitignoreUseCase.execute(projectRoot, aiddGitignoreEntries(manifest));
}
}
diff --git a/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts b/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts
index 6e0e6afc0..f64dba090 100644
--- a/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts
+++ b/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts
@@ -1,4 +1,4 @@
-import { AIDD_DIR, RUNS_ENTRY } from "../../../kernel/paths.js";
+import { AIDD_DIR } from "../../../kernel/paths.js";
import { machineLocalFilesOf } from "../../tools/domain/registry.js";
import type { Manifest } from "./manifest.js";
@@ -10,5 +10,5 @@ export function aiddGitignoreEntries(manifest: Manifest): string[] {
const machineLocal = manifest
.getInstalledToolIds()
.flatMap((toolId) => machineLocalFilesOf(toolId));
- return [`${AIDD_DIR}/cache/`, RUNS_ENTRY, ...new Set(machineLocal)];
+ return [`${AIDD_DIR}/cache/`, ...new Set(machineLocal)];
}
diff --git a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts
index 8aed72d64..a41330a39 100644
--- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts
+++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts
@@ -11,7 +11,7 @@ import { readManifestFile } from "./manifest-file-io.js";
* rule, same file I/O. Only the path differs, and what a version-refusal message names to fix it.
*
* `delete()` removes only `manifest.json` itself, never its parent directory: `userConfigDir()`
- * also holds `auth.json`, `marketplaces.json`, `references.json` and `telemetry/`, none of which
+ * also holds `auth.json`, `marketplaces.json`, and `references.json`, none of which
* this repository owns. Pruning it once empty, as the project adapter does with `.aidd/`, would be
* a live bug here.
*/
diff --git a/cli/src/contexts/telemetry/application/branch-declarations.ts b/cli/src/contexts/telemetry/application/branch-declarations.ts
new file mode 100644
index 000000000..af27484fa
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/branch-declarations.ts
@@ -0,0 +1,68 @@
+import type { BranchConfigBinding } from "../domain/branch-binding.js";
+import {
+ type BranchRole,
+ branchRoleOf,
+ currentBranchOf,
+} from "../domain/declaration/branch-role.js";
+import { lockWaitFor, type TaskDeclaration } from "../domain/declaration/task-declaration.js";
+import type { BranchBindingSource } from "../domain/ports/branch-binding-source.js";
+import type { BranchBindingStore } from "../domain/ports/branch-binding-store.js";
+import type { SnapshotBindingsUseCase } from "./snapshot-bindings-use-case.js";
+
+export interface CurrentBranch {
+ /** `null` when `HEAD` is detached. */
+ readonly name: string | null;
+ readonly role: BranchRole;
+ /** What the branch declares: only a working branch is ever read for one. */
+ readonly declared: BranchConfigBinding | null;
+}
+
+export type BranchOutcome =
+ | { readonly status: "bound"; readonly branch: string }
+ | {
+ readonly status: "untouched";
+ readonly reason: "default-branch" | "detached";
+ readonly branch: string | null;
+ };
+
+/** The branch being worked on and what is declared on it. */
+export class BranchDeclarations {
+ constructor(
+ private readonly store: BranchBindingStore,
+ private readonly source: BranchBindingSource,
+ private readonly snapshots: SnapshotBindingsUseCase
+ ) {}
+
+ async current(root: string): Promise {
+ const heads = await this.store.heads(root);
+ const role = branchRoleOf(heads.head, heads.originHead);
+ const name = currentBranchOf(heads.head);
+ const declared =
+ role === "working"
+ ? ((await this.source.bindings(root)).find((binding) => binding.branch === name) ?? null)
+ : null;
+ return { name, role, declared };
+ }
+
+ /** Declares on a working branch and snapshots at once, while the branch's reflog still
+ * tells when it was created. The default branch and a detached head are left alone. */
+ async declare(
+ repositoryId: string,
+ root: string,
+ declaration: TaskDeclaration
+ ): Promise {
+ const heads = await this.store.heads(root);
+ const role = branchRoleOf(heads.head, heads.originHead);
+ const branch = currentBranchOf(heads.head);
+ if (role !== "working" || branch === null) {
+ return {
+ status: "untouched",
+ reason: role === "detached" ? "detached" : "default-branch",
+ branch,
+ };
+ }
+ await this.store.declare(root, branch, declaration);
+ await this.snapshots.execute(repositoryId, root, lockWaitFor(declaration.by));
+ return { status: "bound", branch };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/consent-log.ts b/cli/src/contexts/telemetry/application/consent-log.ts
new file mode 100644
index 000000000..d5124d010
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/consent-log.ts
@@ -0,0 +1,76 @@
+import type { CloneIdentity } from "../domain/consent/clone-identity.js";
+import { sameClone } from "../domain/consent/clone-identity.js";
+import {
+ type ConsentEvent,
+ type ConsentInterval,
+ covers,
+ foldConsent,
+ isOpen,
+} from "../domain/consent/consent-history.js";
+import type { ConsentHistory } from "../domain/ports/consent-history.js";
+
+/** When each clone consented, read once and kept current as it is written. Callers hold the
+ * ledger's lock, so what was read is what is there, except for a hook that closed an interval
+ * meanwhile: closing is idempotent, so that costs nothing. */
+export class ConsentLog {
+ private intervals: readonly ConsentInterval[];
+
+ private constructor(
+ private readonly history: ConsentHistory,
+ private events: readonly ConsentEvent[],
+ /** A line of the file was not an event: nothing is stored for any clone. */
+ readonly damaged: boolean
+ ) {
+ this.intervals = foldConsent(events);
+ }
+
+ static async load(history: ConsentHistory): Promise {
+ const { events, damaged } = await history.read();
+ return new ConsentLog(history, events, damaged);
+ }
+
+ /** Whether one of the clone's intervals covered `at`, in milliseconds since the epoch. */
+ covers(clone: CloneIdentity, at: number): boolean {
+ return covers(this.intervals, clone, at);
+ }
+
+ /** When the earliest interval opened; infinity when no clone ever opted in. */
+ firstOpened(): number {
+ return Math.min(...this.intervals.map((interval) => interval.from));
+ }
+
+ /** The intervals with no end yet. */
+ openIntervals(): ConsentInterval[] {
+ return this.intervals.filter(isOpen);
+ }
+
+ /** The clone's intervals with no end yet. */
+ openFor(clone: CloneIdentity): ConsentInterval[] {
+ return this.openIntervals().filter((interval) => sameClone(interval.clone, clone));
+ }
+
+ /** Whether the clone had an interval named `token` that has since been closed. */
+ hasClosed(clone: CloneIdentity, token: string): boolean {
+ return this.intervals.some(
+ (interval) =>
+ !isOpen(interval) && interval.token === token && sameClone(interval.clone, clone)
+ );
+ }
+
+ /** Opens an interval for the clone at `at`, named by `token`. */
+ async open(clone: CloneIdentity, token: string, at: Date): Promise {
+ await this.write({ kind: "open", token, clone, at: at.toISOString() });
+ }
+
+ /** Closes the interval named by `token` at `at`, unless it is not open. */
+ async close(token: string, at: Date): Promise {
+ if (!this.openIntervals().some((interval) => interval.token === token)) return;
+ await this.write({ kind: "close", token, at: at.toISOString() });
+ }
+
+ private async write(event: ConsentEvent): Promise {
+ await this.history.append(event);
+ this.events = [...this.events, event];
+ this.intervals = foldConsent(this.events);
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/consented-repositories.ts b/cli/src/contexts/telemetry/application/consented-repositories.ts
new file mode 100644
index 000000000..fe1041cd1
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/consented-repositories.ts
@@ -0,0 +1,60 @@
+import type { ConsentHistory } from "../domain/ports/consent-history.js";
+import type { ConsentSource } from "../domain/ports/consent-source.js";
+import type { RepositoryLocator } from "../domain/ports/repository-locator.js";
+import { repositoryIdOf } from "../domain/repository-identity.js";
+import { consentOf, tokenOfKey } from "../domain/telemetry-consent.js";
+import { ConsentLog } from "./consent-log.js";
+
+export type RefusalReason =
+ | "environment"
+ | "outside-repository"
+ | "unidentified-repository"
+ | "no-consent"
+ | "unreadable-consent";
+
+export type ConsentedRepository =
+ | {
+ readonly status: "open";
+ readonly repositoryId: string;
+ readonly root: string;
+ /** When the interval the key names opened. */
+ readonly since: string;
+ }
+ | { readonly status: "refused"; readonly reason: RefusalReason };
+
+/** The repository a directory is in, when its clone is measured: its key names an interval
+ * that is open for it, the same test the hooks make. A declaration is stored only for a clone
+ * that asked to be measured, never for one whose key was set by hand or came with a copy. */
+export class ConsentedRepositories {
+ constructor(
+ private readonly locator: RepositoryLocator,
+ private readonly consents: ConsentSource,
+ private readonly history: ConsentHistory
+ ) {}
+
+ async open(cwd: string): Promise {
+ const located = await this.locator.locate(cwd);
+ if (located.status === "unreadable") return { status: "refused", reason: "unreadable-consent" };
+ if (located.status !== "repository") return { status: "refused", reason: "outside-repository" };
+ const repositoryId = repositoryIdOf(located);
+ if (repositoryId === null) return { status: "refused", reason: "unidentified-repository" };
+ const reading = await this.consents.read(located.root);
+ const consent = consentOf(reading);
+ if (consent === "unreadable") return { status: "refused", reason: "unreadable-consent" };
+ if (consent === "absent") return { status: "refused", reason: "no-consent" };
+ const log = await ConsentLog.load(this.history);
+ // A log that cannot be read, or a clone the platform cannot identify, measures nothing.
+ if (log.damaged || located.clone === null) {
+ return { status: "refused", reason: "unreadable-consent" };
+ }
+ const token = tokenOfKey(reading.kind === "value" ? reading.value : null);
+ const named = log.openFor(located.clone).find((interval) => interval.token === token);
+ if (named === undefined) return { status: "refused", reason: "no-consent" };
+ return {
+ status: "open",
+ repositoryId,
+ root: located.root,
+ since: new Date(named.from).toISOString(),
+ };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/declare-task-use-case.ts b/cli/src/contexts/telemetry/application/declare-task-use-case.ts
new file mode 100644
index 000000000..7b6840bcd
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/declare-task-use-case.ts
@@ -0,0 +1,69 @@
+import type { TaskDeclaration } from "../domain/declaration/task-declaration.js";
+import {
+ type DeclarationRequest,
+ type DeclaredBy,
+ declarationOf,
+ lockWaitFor,
+} from "../domain/declaration/task-declaration.js";
+import type { BindingsLock } from "../domain/ports/bindings/bindings-lock.js";
+import type { SessionBindingStore } from "../domain/ports/bindings/session-binding-store.js";
+import type { BranchDeclarations, BranchOutcome } from "./branch-declarations.js";
+import type { ConsentedRepositories, RefusalReason } from "./consented-repositories.js";
+
+export interface DeclareInput {
+ readonly cwd: string;
+ readonly request: DeclarationRequest;
+ readonly by: DeclaredBy;
+}
+
+export type DeclareResult =
+ | { readonly status: "refused"; readonly reason: RefusalReason }
+ | {
+ readonly status: "declared";
+ readonly declaration: TaskDeclaration;
+ /** The session the declaration applies to from now, `null` outside a session. */
+ readonly sessionId: string | null;
+ readonly branch: BranchOutcome;
+ };
+
+export interface DeclareOptions {
+ /** `AIDD_TELEMETRY=0`, read by the composition root. */
+ readonly refusedByEnvironment: boolean;
+ /** `CLAUDE_CODE_SESSION_ID`, read by the composition root; `null` outside a session. */
+ readonly sessionId: string | null;
+ readonly now: () => Date;
+}
+
+/** Binds what a person works on to a task: the running session from now on, and the working
+ * branch for good. The session line is appended under the bindings lock, and the snapshot that
+ * follows a branch declaration takes it for itself: never the ledger's lock, which an ingest
+ * holds for as long as it reads, and a person is waiting on this. */
+export class DeclareTaskUseCase {
+ constructor(
+ private readonly repositories: ConsentedRepositories,
+ private readonly sessions: SessionBindingStore,
+ private readonly branches: BranchDeclarations,
+ private readonly lock: BindingsLock,
+ private readonly options: DeclareOptions
+ ) {}
+
+ async execute(input: DeclareInput): Promise {
+ if (this.options.refusedByEnvironment) return { status: "refused", reason: "environment" };
+ const repository = await this.repositories.open(input.cwd);
+ if (repository.status === "refused") return repository;
+ const declaration = declarationOf(input.request, this.options.now(), input.by);
+ const { sessionId } = this.options;
+ if (sessionId !== null) {
+ await this.lock.exclusively(
+ () => this.sessions.append(sessionId, declaration),
+ lockWaitFor(declaration.by)
+ );
+ }
+ const branch = await this.branches.declare(
+ repository.repositoryId,
+ repository.root,
+ declaration
+ );
+ return { status: "declared", declaration, sessionId, branch };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts
deleted file mode 100644
index 2e3e101be..000000000
--- a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts
+++ /dev/null
@@ -1,341 +0,0 @@
-import { describeError } from "../../../kernel/describe-error.js";
-import type { VersionReader } from "../../../kernel/ports/version-reader.js";
-import { AI_TOOL_IDS, type AiToolId } from "../../../kernel/tool.js";
-import { buildHostRegistration } from "../../tools/domain/host-plugin-registration.js";
-import type { HostPluginRegistryReader } from "../../tools/domain/ports/host-plugin-registry-reader.js";
-import { getAiToolConfig, resolvePluginsCapability } from "../../tools/domain/registry.js";
-import {
- SESSION_TRAILER_DELEGATE_FILE,
- SESSION_TRAILER_TOKEN,
-} from "../domain/formats/commit-session-trailer.js";
-import type { HookTrustReader } from "../domain/ports/hook-trust-reader.js";
-import type { InstalledPluginsReader } from "../domain/ports/installed-plugins-reader.js";
-import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js";
-import type { RunJournal, RunJournalReader } from "../domain/ports/run-journal-reader.js";
-import type { SessionCostReader } from "../domain/ports/session-cost-reader.js";
-import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js";
-import type { TelemetrySink } from "../domain/ports/telemetry-sink.js";
-import type { VersionControl } from "../domain/ports/version-control.js";
-import { resolveSessionAnchor } from "../domain/session-anchor.js";
-import {
- anchorProjectElsewhere,
- resolveSessionProject,
- type SessionProject,
-} from "../domain/session-project.js";
-import {
- attributeMoment,
- buildStepIntervals,
- type StepAttributionSource,
-} from "../domain/step-attribution.js";
-import {
- diagnoseTelemetryClaims,
- type TelemetryClaim,
- type TelemetryClaimJournal,
- type TelemetryClaimToolRead,
- type TelemetryCodexHookTrust,
- type TelemetryEvidence,
-} from "../domain/telemetry-claim.js";
-import type { TelemetryExportLeftover } from "../domain/telemetry-export-leftover.js";
-import {
- buildTelemetryAllowedSetup,
- type TelemetryHostRegistrationSetup,
- type TelemetryIdentitySetup,
- type TelemetryPluginVersionSetup,
- type TelemetryRecorderDeclarationSetup,
- type TelemetrySetup,
-} from "../domain/telemetry-setup.js";
-
-const DEFAULT_RUNS_DIR_LABEL = "aidd_docs/runs";
-
-export interface DiagnoseTelemetryUncoveredTool {
- readonly tool: AiToolId;
- readonly reason: string;
-}
-
-/** What `aidd telemetry check` answers with. `gate` is mutually exclusive with `claims`: a gated
- * run judges nothing. `leftoverExportConfig` and `setup` are gathered either side of the gate โ a
- * stale export lives in a tool's own settings file, whatever this project's switch says. */
-export type DiagnoseTelemetryResult =
- | {
- readonly gate: string;
- readonly setup: TelemetrySetup;
- readonly leftoverExportConfig: readonly TelemetryExportLeftover[];
- }
- | {
- readonly gate?: undefined;
- readonly setup: TelemetrySetup;
- readonly claims: readonly TelemetryClaim[];
- readonly uncovered: readonly DiagnoseTelemetryUncoveredTool[];
- readonly leftoverExportConfig: readonly TelemetryExportLeftover[];
- };
-
-/** How far back the trailer count looks. A count rather than a date: the cost is the same on any
- * repository, and enough to show whether recent commits are being stamped. */
-const COMMITS_EXAMINED_FOR_TRAILER = 20;
-
-export interface DiagnoseTelemetryOptions {
- readonly projectRoot: string;
- readonly env: NodeJS.ProcessEnv;
-}
-
-function toClaimJournal(journal: RunJournal): TelemetryClaimJournal {
- return {
- vendorId: journal.session?.vendor_id,
- sessionStartAt: journal.session?.at,
- turnClosed: journal.boundaries.length > 0,
- };
-}
-
-function isCovered(tool: AiToolId): boolean {
- return getAiToolConfig(tool).telemetryLocalRead.kind === "declared";
-}
-
-function coveredTools(): readonly AiToolId[] {
- return AI_TOOL_IDS.filter(isCovered);
-}
-
-function uncoveredTools(): readonly DiagnoseTelemetryUncoveredTool[] {
- return AI_TOOL_IDS.filter((tool) => !isCovered(tool)).map((tool) => {
- const localRead = getAiToolConfig(tool).telemetryLocalRead;
- return {
- tool,
- reason: localRead.kind === "unsupported" ? localRead.reason : "no reader wired yet",
- };
- });
-}
-
-/** The plugin version the hook stamped, from the most recently opened session carrying one: an
- * upgrade mid-period leaves older lines naming the older build. A session carrying none is
- * skipped, never counted as an absence. */
-function pluginVersionFrom(journals: readonly RunJournal[]): TelemetryPluginVersionSetup {
- const sessions = journals.map((journal) => journal.session).filter(isPresent);
- if (sessions.length === 0) return { kind: "nothing-journalled" };
- const withVersion = sessions
- .filter((session) => session.plugin_version !== undefined)
- .sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
- const newest = withVersion[0];
- return newest?.plugin_version === undefined
- ? { kind: "unrecorded" }
- : { kind: "recorded", version: newest.plugin_version };
-}
-
-function isPresent(value: T | undefined): value is T {
- return value !== undefined;
-}
-
-/**
- * Gathers every claim's evidence, then hands it to the pure judge in `domain/telemetry-claim.ts`.
- * Never writes: the question is only ever "would a read of this session's figures work".
- */
-export class DiagnoseTelemetryUseCase {
- constructor(
- private readonly evidence: TelemetryEvidenceReader,
- private readonly git: VersionControl,
- private readonly runJournalReader: RunJournalReader,
- private readonly readers: ReadonlyMap,
- private readonly hookTrustReader: HookTrustReader,
- private readonly personIdentityStore: PersonIdentityStore,
- private readonly telemetrySink: TelemetrySink,
- private readonly currentVersion: VersionReader,
- private readonly installedPlugins: InstalledPluginsReader,
- private readonly hostRegistries: ReadonlyMap
- ) {}
-
- async execute(options: DiagnoseTelemetryOptions): Promise {
- // Gathered before the gate and regardless of it: a stale export exports whether or not this
- // project's switch is on, and setup is what a person switched off still needs to see.
- const journals = await this.runJournalReader.list();
- const leftoverExportConfig = await this.evidence.findLeftoverExportConfig(options.projectRoot);
- const setup = await this.gatherSetup(options, journals);
- const gate = await this.gateReason(options);
- if (gate !== null) return { gate, setup, leftoverExportConfig };
- const evidence = await this.gatherEvidence(options, setup.recorderDeclaration, journals);
- const claims = diagnoseTelemetryClaims(evidence);
- return { setup, claims, uncovered: uncoveredTools(), leftoverExportConfig };
- }
-
- private async gatherSetup(
- options: DiagnoseTelemetryOptions,
- journals: readonly RunJournal[]
- ): Promise {
- const [switchSetup, recorderDeclaration] = await Promise.all([
- this.evidence.readSwitchSetup(options.projectRoot),
- this.evidence.readRecorderDeclaration(options.projectRoot),
- ]);
- return {
- allowed: buildTelemetryAllowedSetup(switchSetup, options.env),
- identity: await this.readIdentitySetup(),
- recordsLocation: { path: this.telemetrySink.rootDir },
- recorderDeclaration,
- hostRegistration: await this.readHostRegistration(options.projectRoot),
- commitTrailer: await this.git.readCommitTrailerSetup(
- options.projectRoot,
- SESSION_TRAILER_DELEGATE_FILE,
- SESSION_TRAILER_TOKEN,
- COMMITS_EXAMINED_FOR_TRAILER
- ),
- versions: {
- cli: this.currentVersion.get(),
- plugin: pluginVersionFrom(journals),
- },
- };
- }
-
- /** Every plugin the manifest records, against what each host's registry says. Driven from the
- * manifest, never from a settings file: a plugin whose marketplace does not resolve is skipped
- * silently there, so a settings-first comparison would read absence as agreement. */
- private async readHostRegistration(projectRoot: string): Promise {
- let recorded: Awaited>;
- try {
- recorded = await this.installedPlugins.read();
- } catch (error) {
- // A damaged manifest throws rather than returning null: reported, never fatal, and named โ
- // the `recorder declared` row scans the same file's raw JSON while this goes through the
- // manifest's own validation, so the two rows can disagree about one file.
- return {
- ...buildHostRegistration([]),
- manifestUnreadable: `${this.installedPlugins.path} โ ${describeError(error)}`,
- };
- }
- if (recorded === null) return buildHostRegistration([]);
- const evidence = await Promise.all(
- // Filtered before the read, never after: a tool with no plugin recorded would otherwise pay
- // a home-directory read whose result is thrown away on every `check`.
- AI_TOOL_IDS.filter((tool) => (recorded.get(tool) ?? []).length > 0).map(async (tool) => ({
- tool,
- plugins: (recorded.get(tool) ?? []).map((plugin) => ({
- name: plugin.name,
- marketplace: plugin.marketplace,
- })),
- reading: await this.hostRegistries.get(tool)?.read(projectRoot),
- declaresNativeActivation: resolvePluginsCapability(tool)?.nativeActivation != null,
- }))
- );
- return buildHostRegistration(evidence);
- }
-
- // `readStrict()` throws on a damaged file, unlike the `read()` every other consumer uses: this
- // caller must tell "nobody chose" apart from "could not be read", so it catches.
- private async readIdentitySetup(): Promise {
- const path = this.personIdentityStore.filePath;
- try {
- const identity = await this.personIdentityStore.readStrict();
- return { attached: identity !== null, path, readable: true };
- } catch {
- return { attached: false, path, readable: false };
- }
- }
-
- private async gatherEvidence(
- options: DiagnoseTelemetryOptions,
- recorderDeclaration: TelemetryRecorderDeclarationSetup,
- journals: readonly RunJournal[]
- ): Promise {
- const currentSessionId = resolveSessionAnchor(options.env);
- const unrecognisedPayload = await this.evidence.readUnrecognisedPayload(options.projectRoot);
- const hookTrust = await this.resolveHookTrust(options.env, currentSessionId);
- const toolReads = await this.gatherToolReads(journals);
- const anchorInAnotherProject = await this.resolveAnchorProject(currentSessionId, journals);
- return {
- journals: journals.map(toClaimJournal),
- toolReads,
- runsDirLabel: DEFAULT_RUNS_DIR_LABEL,
- currentSessionId,
- unrecognisedPayloadAt: unrecognisedPayload?.at,
- hookTrust,
- ...(anchorInAnotherProject === null ? {} : { anchorInAnotherProject }),
- recorderDeclared: recorderDeclaration.declared,
- recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0,
- foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(),
- };
- }
-
- // Stops the run before any claim is evaluated: neither fact is evidence about the hook,
- // both are facts about whether there is anything here for it to have written.
- private async gateReason(options: DiagnoseTelemetryOptions): Promise {
- if (!(await this.evidence.isTelemetryEnabled(options.projectRoot, options.env))) {
- return "measurement is off โ nothing to check until it is turned on";
- }
- if (!(await this.git.isRepository(options.projectRoot))) {
- return (
- "not a git repository โ the hook has nowhere to write here, not a hook that failed " +
- "to fire"
- );
- }
- return null;
- }
-
- /** Asked only of an anchor that left no run file here: one journalled here is ours already. */
- private async resolveAnchorProject(
- currentSessionId: string | undefined,
- journals: readonly RunJournal[]
- ): Promise {
- if (currentSessionId === undefined) return null;
- if (journals.some((journal) => journal.session?.vendor_id === currentSessionId)) return null;
- const here = journals
- .map(resolveSessionProject)
- .filter((project): project is SessionProject => project !== null);
- if (here.length === 0) return null;
- const stored = await this.telemetrySink.readRecordsForVendor(currentSessionId);
- return anchorProjectElsewhere(here, stored);
- }
-
- // Only Codex gates a hook behind a trust grant it can decline in silence: a session
- // running under any other tool has nothing to read here, and asks nothing of it.
- private async resolveHookTrust(
- env: NodeJS.ProcessEnv,
- currentSessionId: string | undefined
- ): Promise {
- if (env.CODEX_THREAD_ID === undefined || currentSessionId === undefined) return undefined;
- return this.hookTrustReader.read();
- }
-
- private async gatherToolReads(
- journals: readonly RunJournal[]
- ): Promise {
- const covered = coveredTools();
- const reads: TelemetryClaimToolRead[] = [];
- for (const journal of journals) {
- const sessionId = journal.session?.vendor_id;
- if (sessionId === undefined) continue;
- const intervals = buildStepIntervals(journal);
- for (const tool of covered) {
- reads.push(await this.readOneTool(tool, sessionId, intervals));
- }
- }
- return reads;
- }
-
- // A reader's contract promises never to throw, and this catches anyway: a diagnostic that
- // crashed on one unreadable file would answer nothing about every other claim.
- private async readOneTool(
- tool: AiToolId,
- sessionId: string,
- intervals: ReturnType
- ): Promise {
- const reader = this.readers.get(tool);
- const hasIntervals = intervals.length > 0;
- if (!reader) return { tool, sessionFound: false, hasIntervals, records: [] };
- try {
- const result = await reader.read(sessionId);
- const records = result.records.map((record) => stampAttribution(record, intervals));
- return { tool, sessionFound: result.sessionFound, hasIntervals, records };
- } catch (error) {
- return {
- tool,
- sessionFound: false,
- hasIntervals,
- records: [],
- error: error instanceof Error ? error.message : String(error),
- };
- }
- }
-}
-
-function stampAttribution(
- record: { readonly step?: string; readonly event_timestamp?: string },
- intervals: ReturnType
-): { readonly stepAttribution: StepAttributionSource } {
- if (record.step !== undefined) return { stepAttribution: "tool-stated" };
- return { stepAttribution: attributeMoment(intervals, record.event_timestamp).source };
-}
diff --git a/cli/src/contexts/telemetry/application/directory-resolver.ts b/cli/src/contexts/telemetry/application/directory-resolver.ts
new file mode 100644
index 000000000..5e1385849
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/directory-resolver.ts
@@ -0,0 +1,204 @@
+import { type CloneIdentity, cloneKey, sameClone } from "../domain/consent/clone-identity.js";
+import type { ConsentHistory } from "../domain/ports/consent-history.js";
+import type { ConsentSource } from "../domain/ports/consent-source.js";
+import type { RepositoryLocator } from "../domain/ports/repository-locator.js";
+import type { ResolutionStore } from "../domain/ports/resolution-store.js";
+import { repositoryIdOf } from "../domain/repository-identity.js";
+import {
+ cwdKey,
+ type NotStoredReason,
+ ownerAt,
+ type RepositoryResolution,
+ resolutionKey,
+ sameResolution,
+} from "../domain/repository-resolution.js";
+import { tokenOfKey } from "../domain/telemetry-consent.js";
+import { ConsentLog } from "./consent-log.js";
+
+export type DirectoryOutcome =
+ | {
+ readonly stored: RepositoryResolution;
+ /** Resolved from the directory as it stands, not from what was remembered of it. */
+ readonly live: boolean;
+ }
+ | { readonly skipped: NotStoredReason };
+
+/** What the machine decides for a run: how it spells a directory, and what time it is. */
+export interface ResolutionEnvironment {
+ /** macOS and Windows file systems answer one directory to several spellings. */
+ readonly caseInsensitiveFileSystem: boolean;
+ readonly now: () => Date;
+}
+
+type Directory =
+ | { readonly skipped: NotStoredReason }
+ | {
+ /** Every clone the directory was seen alive in. */
+ readonly owners: readonly [RepositoryResolution, ...RepositoryResolution[]];
+ /** The one it is alive in now, when it is. */
+ readonly current: RepositoryResolution | null;
+ };
+
+/** Decides, for a billed call, whether it is stored: the rule is in the usage contract. The
+ * directory it was made in must have been seen alive, in a clone, and that clone must have had
+ * an open consent interval at the call's time. A clone is told from another made at the same
+ * path by its identity, never by its path or its remote. The clone's own key is not asked at
+ * the call: the intervals are the truth, and an interval is closed as soon as anything sees the
+ * key stop naming it. */
+export class DirectoryResolver {
+ constructor(
+ private readonly locator: RepositoryLocator,
+ private readonly consentSource: ConsentSource,
+ private readonly store: ResolutionStore,
+ private readonly history: ConsentHistory,
+ private readonly environment: ResolutionEnvironment
+ ) {}
+
+ /** One run, under the ledger's lock. Every open interval is looked at first, so that a clone
+ * that stopped consenting, or went, is closed whether or not a call of it is read. */
+ async open(): Promise {
+ const run = new ResolutionRun(
+ this.locator,
+ this.consentSource,
+ this.store,
+ await this.store.load(),
+ await ConsentLog.load(this.history),
+ this.environment
+ );
+ await run.observeOpenConsents();
+ return run;
+ }
+}
+
+export class ResolutionRun {
+ private changed = false;
+ private readonly directories = new Map();
+ /** Clones whose git config could not be read: their intervals are neither kept nor closed. */
+ private readonly unreadable = new Set();
+
+ constructor(
+ private readonly locator: RepositoryLocator,
+ private readonly consentSource: ConsentSource,
+ private readonly store: ResolutionStore,
+ private readonly remembered: Map,
+ private readonly consents: ConsentLog,
+ private readonly environment: ResolutionEnvironment
+ ) {}
+
+ /** The earliest instant a call can be covered at, in milliseconds: a transcript last written
+ * before it holds nothing to store. Everything when the log cannot be trusted, so that each
+ * call is still counted under its reason; nothing when no clone ever opted in. */
+ get coveredFrom(): number {
+ if (this.consents.damaged) return Number.NEGATIVE_INFINITY;
+ return this.consents.firstOpened();
+ }
+
+ /** A line of the consent log was not an event: nothing is stored for any clone. */
+ get consentLogDamaged(): boolean {
+ return this.consents.damaged;
+ }
+
+ /** Keeps what this run learned. */
+ async close(): Promise {
+ if (this.changed) await this.store.save(this.remembered);
+ }
+
+ /** Closes, at this moment, the earliest anyone knows, every open interval whose clone is
+ * another directory now, or no longer names the interval's token in its key. A clone with
+ * nothing at its path is left open: it may only be out of reach, and a directory made at its
+ * path since is seen as another one. */
+ async observeOpenConsents(): Promise {
+ for (const interval of this.consents.openIntervals()) {
+ const reading = await this.consentSource.readClone(interval.clone);
+ if (reading.kind === "unreadable") {
+ this.unreadable.add(cloneKey(interval.clone));
+ continue;
+ }
+ if (reading.kind === "absent") continue;
+ const named = reading.kind === "value" && tokenOfKey(reading.value) === interval.token;
+ if (!named) await this.consents.close(interval.token, this.environment.now());
+ }
+ }
+
+ /** The verdict on a call made in `cwd` at `at`. */
+ async resolve(cwd: string, at: string): Promise {
+ // A consent log that cannot be read says nothing for any clone.
+ if (this.consents.damaged) return { skipped: "unreadable-consent" };
+ const directory = await this.directoryOf(cwd);
+ if ("skipped" in directory) return directory;
+ const instant = Date.parse(at);
+ const owner = ownerAt(directory.owners, instant);
+ if (this.unreadable.has(cloneKey(owner.clone))) return { skipped: "unreadable-consent" };
+ const live = directory.current !== null && sameClone(directory.current.clone, owner.clone);
+ if (!this.consents.covers(owner.clone, instant)) {
+ const closed = live && (await this.keyNamesClosedInterval(owner.clone));
+ return { skipped: closed ? "consent-closed" : "no-consent" };
+ }
+ return { stored: owner, live };
+ }
+
+ /** Whether the clone's own key, as it stands, names an interval of it that was closed: it
+ * opted in once, so the person is told to run `on` again, not that it never did. */
+ private async keyNamesClosedInterval(clone: CloneIdentity): Promise {
+ const reading = await this.consentSource.readClone(clone);
+ const token = reading.kind === "value" ? tokenOfKey(reading.value) : null;
+ return token !== null && this.consents.hasClosed(clone, token);
+ }
+
+ private async directoryOf(cwd: string): Promise {
+ const held = this.directories.get(cwd);
+ if (held !== undefined) return held;
+ const directory = await this.look(cwd);
+ this.directories.set(cwd, directory);
+ return directory;
+ }
+
+ private async look(cwd: string): Promise {
+ const dir = cwdKey(cwd, this.environment.caseInsensitiveFileSystem);
+ const located = await this.locator.locate(cwd);
+ if (located.status === "gone") {
+ const [first, ...others] = this.ownersOf(dir);
+ return first === undefined
+ ? { skipped: "never-seen-alive" }
+ : { owners: [first, ...others], current: null };
+ }
+ if (located.status === "outside-repository") return { skipped: "outside-repo" };
+ // A directory git or the file system would not answer for may be a clone: not known to be
+ // outside any.
+ if (located.status === "unreadable") return { skipped: "unreadable-consent" };
+ // A repository with no origin and no commit has nothing to be named by.
+ const id = repositoryIdOf(located);
+ if (id === null) return { skipped: "outside-repo" };
+ // A clone the platform cannot identify cannot be told from another: fail closed.
+ if (located.clone === null) return { skipped: "unreadable-consent" };
+ const current = this.remember(dir, id, located.root, located.clone);
+ const others = this.ownersOf(dir).filter((owner) => !sameClone(owner.clone, current.clone));
+ return { owners: [current, ...others], current };
+ }
+
+ /** Keeps the first sighting of a directory in a clone, and what it was found to be since. */
+ private remember(
+ dir: string,
+ repositoryId: string,
+ root: string,
+ clone: CloneIdentity
+ ): RepositoryResolution {
+ const key = resolutionKey(dir, clone);
+ const held = this.remembered.get(key);
+ const seen = {
+ dir,
+ repository_id: repositoryId,
+ root,
+ clone,
+ seen_at: held?.seen_at ?? this.environment.now().toISOString(),
+ };
+ if (sameResolution(held, seen)) return seen;
+ this.remembered.set(key, seen);
+ this.changed = true;
+ return seen;
+ }
+
+ private ownersOf(dir: string): RepositoryResolution[] {
+ return [...this.remembered.values()].filter((resolution) => resolution.dir === dir);
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts
deleted file mode 100644
index 7042d1f8f..000000000
--- a/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import { errorMessage } from "../../../kernel/describe-error.js";
-import { RUNS_ENTRY } from "../../../kernel/paths.js";
-import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js";
-import type { RunJournalStore } from "../domain/ports/run-journal-reader.js";
-import type { TelemetrySink } from "../domain/ports/telemetry-sink.js";
-import type { VersionControl } from "../domain/ports/version-control.js";
-import type {
- TelemetryHistoryReading,
- TelemetryMachineIdentityRemoval,
- TelemetryMachineSinkRemoval,
- TelemetryProjectJournalRemoval,
- TelemetryRemovalPreview,
-} from "../domain/telemetry-removal.js";
-
-export interface ForgetTelemetryOptions {
- readonly projectRoot: string;
-}
-
-export interface TelemetryRemovalFailure {
- readonly path: string;
- readonly reason: string;
-}
-
-export interface TelemetryRemovalOutcome {
- readonly removed: number;
- readonly failed: readonly TelemetryRemovalFailure[];
-}
-
-export interface TelemetryRemovalResult {
- readonly journal: TelemetryRemovalOutcome;
- readonly sink: TelemetryRemovalOutcome;
- readonly identity: TelemetryRemovalOutcome;
- /** Repeated from the preview: history does not become reachable by having removed the rest,
- * so this is the exact same reading, not a fresh one. */
- readonly history: TelemetryHistoryReading;
-}
-
-/**
- * Shows, then removes, what this tool measured about one person, never both in the same call.
- * `preview()` alone resolves every location; `remove()` takes exactly that value and resolves
- * none of its own, so deleting something nobody was shown is inexpressible, not merely untested.
- * Whether to call `remove()` at all is the command layer's decision; a refusal is never a throw.
- */
-export class ForgetTelemetryUseCase {
- constructor(
- private readonly sink: TelemetrySink,
- private readonly runJournalReader: RunJournalStore,
- private readonly identity: PersonIdentityStore,
- private readonly git: VersionControl
- ) {}
-
- /** Resolves every location once, and touches nothing โ a person sees exactly this value
- * before anything is asked to go. */
- async preview(options: ForgetTelemetryOptions): Promise {
- const [dayFileNames, runFileNames, isRepo, tracked, hasHistory, identityState] =
- await Promise.all([
- this.sink.listDayFiles(),
- this.runJournalReader.listRunFiles(),
- this.git.isRepository(options.projectRoot),
- this.git.listTrackedFiles(options.projectRoot, RUNS_ENTRY),
- this.git.hasHistoryFor(options.projectRoot, RUNS_ENTRY),
- this.identityState(),
- ]);
- return {
- journal: { scope: "project", path: this.runJournalReader.runsDir, runFileNames },
- sink: { scope: "machine", path: this.sink.rootDir, dayFileNames },
- identity: { scope: "machine", path: this.identity.filePath, ...identityState },
- history: this.historyReading(isRepo, tracked, hasHistory),
- };
- }
-
- private historyReading(
- isRepo: boolean,
- tracked: readonly string[],
- hasHistory: boolean
- ): TelemetryHistoryReading {
- if (!isRepo) return { certainty: "none" };
- if (tracked.length === 0) return { certainty: "possible" };
- return hasHistory
- ? { certainty: "committed", files: tracked }
- : { certainty: "staged", files: tracked };
- }
-
- /** Removes exactly what `preview` resolved, never a location of its own. Every location is
- * attempted whatever the others did: one failure never spares or stops the rest. */
- async remove(preview: TelemetryRemovalPreview): Promise {
- const [journal, sink, identity] = await Promise.all([
- this.removeJournal(preview.journal),
- this.removeSink(preview.sink),
- this.removeIdentity(preview.identity),
- ]);
- return { journal, sink, identity, history: preview.history };
- }
-
- // `readStrict()` throwing is the file existing but being unreadable - exactly the file a
- // person most needs named as present. `null` is the ordinary "nobody opted in" case.
- private async identityState(): Promise<{ present: boolean; unreadable: boolean }> {
- try {
- return { present: (await this.identity.readStrict()) !== null, unreadable: false };
- } catch {
- return { present: true, unreadable: true };
- }
- }
-
- // `journal.path`, never `this.runJournalReader.runsDir` re-read here: this must act on the
- // same value a person was shown.
- private async removeJournal(
- journal: TelemetryProjectJournalRemoval
- ): Promise {
- const failed: TelemetryRemovalFailure[] = [];
- let removed = 0;
- for (const fileName of journal.runFileNames) {
- try {
- await this.runJournalReader.deleteRunFile(journal.path, fileName);
- removed++;
- } catch (error) {
- failed.push({ path: fileName, reason: errorMessage(error) });
- }
- }
- return { removed, failed };
- }
-
- // `sink.path`, for the same reason `removeJournal` uses `journal.path`: the two agree today,
- // but a second computation here would be free to disagree tomorrow.
- private async removeSink(sink: TelemetryMachineSinkRemoval): Promise {
- const failed: TelemetryRemovalFailure[] = [];
- let removed = 0;
- for (const fileName of sink.dayFileNames) {
- try {
- await this.sink.deleteDayFile(sink.path, fileName);
- removed++;
- } catch (error) {
- failed.push({ path: fileName, reason: errorMessage(error) });
- }
- }
- return { removed, failed };
- }
-
- // Gated on the preview's own `identity.present`, never the filesystem at removal time: a file
- // that appeared after a preview said "nothing to remove" must not be deleted and counted.
- private async removeIdentity(
- identity: TelemetryMachineIdentityRemoval
- ): Promise {
- if (!identity.present) return { removed: 0, failed: [] };
- try {
- const wasThere = await this.identity.forget(identity.path);
- return { removed: wasThere ? 1 : 0, failed: [] };
- } catch (error) {
- return { removed: 0, failed: [{ path: identity.path, reason: errorMessage(error) }] };
- }
- }
-}
diff --git a/cli/src/contexts/telemetry/application/forget/forget-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/forget/forget-telemetry-use-case.ts
new file mode 100644
index 000000000..5025915b7
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/forget/forget-telemetry-use-case.ts
@@ -0,0 +1,105 @@
+import type { BindingSnapshotStore } from "../../domain/ports/bindings/binding-snapshot-store.js";
+import type { ConsentHistory } from "../../domain/ports/consent-history.js";
+import type {
+ ErasureEntry,
+ MeasurementErasure,
+} from "../../domain/ports/forget/measurement-erasure.js";
+import type {
+ RepositoryDeclarations,
+ RepositoryKeys,
+} from "../../domain/ports/forget/repository-declarations.js";
+import type { ResolutionStore } from "../../domain/ports/resolution-store.js";
+import type { UsageLedger } from "../../domain/ports/usage-ledger.js";
+
+export interface ForgetPlan {
+ readonly entries: readonly ErasureEntry[];
+ /** Clones still on disk, named by their common git dir, with what their git config holds. */
+ readonly repositories: readonly ({ readonly clone: string } & RepositoryKeys)[];
+ /** Clones that are gone and had consented: they may have left a key nobody can reach. A gone
+ * clone that never consented left nothing, and is not named. */
+ readonly missing: readonly string[];
+ /** Repositories that declared a task and whose location was never recorded. */
+ readonly unlocated: number;
+}
+
+export type ForgetResult =
+ | { readonly status: "preview"; readonly plan: ForgetPlan }
+ | { readonly status: "forgotten"; readonly plan: ForgetPlan };
+
+function nothingToForget(plan: ForgetPlan): boolean {
+ return (
+ plan.entries.length === 0 &&
+ plan.repositories.every((repository) => repository.taskKeys === 0 && !repository.consent)
+ );
+}
+
+/** Erases what measurement kept: previews by default, and removes only on confirmation. */
+export class ForgetTelemetryUseCase {
+ constructor(
+ private readonly erasure: MeasurementErasure,
+ private readonly declarations: RepositoryDeclarations,
+ private readonly snapshots: BindingSnapshotStore,
+ private readonly resolutions: ResolutionStore,
+ private readonly history: ConsentHistory,
+ private readonly ledger: UsageLedger
+ ) {}
+
+ async execute(confirmed: boolean): Promise {
+ const plan = await this.plan();
+ if (!confirmed) return { status: "preview", plan };
+ if (nothingToForget(plan)) return { status: "forgotten", plan };
+ // Held for the whole removal, so no ingest writes into what is being erased. What goes is
+ // read again under the lock, and that is what is reported.
+ return this.ledger.exclusively(async () => {
+ const held = await this.plan();
+ // The declarations first: they are found through the files that follow, so a crash
+ // between the two leaves the way back to them.
+ for (const repository of held.repositories) await this.declarations.clear(repository.clone);
+ await this.erasure.erase();
+ return { status: "forgotten" as const, plan: held };
+ });
+ }
+
+ private async plan(): Promise {
+ const entries = await this.erasure.inventory();
+ const found = await this.clonesRemembered();
+ const repositories: ({ clone: string } & RepositoryKeys)[] = [];
+ const missing: string[] = [];
+ for (const clone of found.clones) {
+ const keys = await this.declarations.count(clone);
+ if (keys !== null) repositories.push({ clone, ...keys });
+ else if (found.consenting.has(clone)) missing.push(clone);
+ }
+ return {
+ entries,
+ repositories,
+ missing,
+ unlocated: found.unlocated,
+ };
+ }
+
+ /** The clones something was kept in: every one a directory was seen alive in, whatever
+ * consent it found there (a clone that ran `off` still holds its key), and every one that
+ * consented, whether or not any session ever ran there. A snapshot carries no path of its
+ * own. */
+ private async clonesRemembered(): Promise<{
+ clones: string[];
+ consenting: Set;
+ unlocated: number;
+ }> {
+ const remembered = [...(await this.resolutions.load()).values()];
+ const consenting = new Set(
+ (await this.history.read()).events.flatMap((event) =>
+ event.kind === "open" ? [event.clone.path] : []
+ )
+ );
+ const clones = new Set([...remembered.map((r) => r.clone.path), ...consenting]);
+ const located = new Set(remembered.map((resolution) => resolution.repository_id));
+ const ids = new Set([...(await this.snapshots.latest()).values()].map((s) => s.repository_id));
+ return {
+ clones: [...clones].sort(),
+ consenting,
+ unlocated: [...ids].filter((id) => !located.has(id)).length,
+ };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/identity/manage-identity-use-case.ts b/cli/src/contexts/telemetry/application/identity/manage-identity-use-case.ts
new file mode 100644
index 000000000..23404311b
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/identity/manage-identity-use-case.ts
@@ -0,0 +1,29 @@
+import { personIdOf } from "../../domain/identity/person-identity.js";
+import type { PersonIdentityStore } from "../../domain/ports/identity/person-identity-store.js";
+
+export type IdentityResult =
+ | { readonly status: "set"; readonly personId: string }
+ | { readonly status: "unset" }
+ | { readonly status: "removed" }
+ | { readonly status: "refused" };
+
+/** Who, if anyone, the measurement names. A person is named only by choosing to be. */
+export class ManageIdentityUseCase {
+ constructor(private readonly store: PersonIdentityStore) {}
+
+ async show(): Promise {
+ const personId = await this.store.read();
+ return personId === null ? { status: "unset" } : { status: "set", personId };
+ }
+
+ async set(typed: string): Promise {
+ const personId = personIdOf(typed);
+ if (personId === null) return { status: "refused" };
+ await this.store.write(personId);
+ return { status: "set", personId };
+ }
+
+ async off(): Promise {
+ return (await this.store.remove()) ? { status: "removed" } : { status: "unset" };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts
new file mode 100644
index 000000000..338fad43d
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts
@@ -0,0 +1,182 @@
+import type { UsageLedger } from "../domain/ports/usage-ledger.js";
+import type { NotStoredReason } from "../domain/repository-resolution.js";
+import { monthOf, type StoredUsage, upsertUsage } from "../domain/stored-usage.js";
+import type { TranscriptPosition } from "../domain/transcript-position.js";
+import type { UsageRecord } from "../domain/usage-record.js";
+import type { DirectoryOutcome, DirectoryResolver, ResolutionRun } from "./directory-resolver.js";
+import type { ReadUsageUseCase, UsageReading } from "./read-usage-use-case.js";
+import type { SnapshotBindingsUseCase } from "./snapshot-bindings-use-case.js";
+
+export interface IngestResult {
+ /** `AIDD_TELEMETRY=0`: nothing was read, nothing was written. */
+ readonly refused: boolean;
+ /** Transcripts that were new or had grown since the last ingest. */
+ readonly filesRead: number;
+ readonly added: number;
+ readonly updated: number;
+ /** Shapes the reader met and did not recognise. */
+ readonly unrecognised: number;
+ /** Lines of the ledger that were not records. */
+ readonly skippedLedgerLines: number;
+ /** Branch declarations newly snapshotted. */
+ readonly snapshots: number;
+ /** Billed calls, by the reason they were not stored. */
+ readonly notStored: Readonly>;
+ /** A line of `ledger/consents.jsonl` was not an event: nothing is stored, on any run, until
+ * it is recovered. */
+ readonly consentLogDamaged: boolean;
+ /** When the oldest transcript still on disk was last written: how far back history can be
+ * read. `null` when there is none, or nothing was read. */
+ readonly oldestTranscriptAt: string | null;
+}
+
+const NOT_STORED: Readonly> = {
+ "outside-repo": 0,
+ "never-seen-alive": 0,
+ "no-consent": 0,
+ "consent-closed": 0,
+ "unreadable-consent": 0,
+ "no-cwd": 0,
+ undated: 0,
+};
+
+/** How far a file's modification time may lag the clock that stamps calls: coarse kernel
+ * ticks, 2 s on FAT, a network file system's own clock. A transcript last written longer than
+ * this before the first interval opened holds nothing that could be stored. */
+const FILE_CLOCK_MARGIN_MS = 24 * 60 * 60 * 1000;
+
+export interface IngestOptions {
+ /** `AIDD_TELEMETRY=0`, read by the composition root. */
+ readonly refusedByEnvironment: boolean;
+}
+
+function moved(before: TranscriptPosition | undefined, now: TranscriptPosition): boolean {
+ return (
+ before === undefined ||
+ before.offset !== now.offset ||
+ before.size !== now.size ||
+ before.identity !== now.identity
+ );
+}
+
+/** Reads what every transcript gained since the last ingest and stores the billed calls of the
+ * projects that opted in, one record per call. Safe to run any number of times, from any
+ * number of processes. */
+export class IngestUsageUseCase {
+ constructor(
+ /** One per tool measured: adding a tool is its format and its transcripts, here. */
+ private readonly readers: readonly ReadUsageUseCase[],
+ private readonly ledger: UsageLedger,
+ private readonly directories: DirectoryResolver,
+ private readonly snapshots: SnapshotBindingsUseCase,
+ private readonly options: IngestOptions
+ ) {}
+
+ async execute(): Promise {
+ if (this.options.refusedByEnvironment) {
+ return {
+ refused: true,
+ filesRead: 0,
+ added: 0,
+ updated: 0,
+ unrecognised: 0,
+ skippedLedgerLines: 0,
+ snapshots: 0,
+ notStored: NOT_STORED,
+ consentLogDamaged: false,
+ oldestTranscriptAt: null,
+ };
+ }
+ return this.ledger.exclusively(() => this.ingest());
+ }
+
+ private async ingest(): Promise {
+ const before = await this.ledger.positions();
+ // The consent log first: a transcript last written before any interval opened is not read.
+ const resolver = await this.directories.open();
+ const reading = await this.readAll(before, resolver.coveredFrom - FILE_CLOCK_MARGIN_MS);
+ const movedPaths = [...reading.positions]
+ .filter(([path, now]) => moved(before.get(path), now))
+ .map(([path]) => path);
+ const filesRead = movedPaths.filter((path) => !reading.skipped.has(path)).length;
+
+ const notStored = { ...NOT_STORED };
+ const toStore: StoredUsage[] = [];
+ const live = new Map();
+ for (const record of reading.records) {
+ const outcome = await this.outcomeOf(record, resolver);
+ if ("skipped" in outcome) {
+ notStored[outcome.skipped] += 1;
+ continue;
+ }
+ toStore.push({ ...record, repository_id: outcome.stored.repository_id });
+ if (outcome.live) live.set(outcome.stored.repository_id, outcome.stored.root);
+ }
+
+ let added = 0;
+ let updated = 0;
+ let skippedLedgerLines = 0;
+ if (toStore.length > 0) {
+ const held = await this.ledger.load();
+ skippedLedgerLines = held.skippedLines;
+ const merged = upsertUsage(held.records, toStore);
+ added = merged.added;
+ updated = merged.updated;
+ // A month that holds a line that is not a record is rewritten with the rest, so the line
+ // is dropped once and the report stops warning of it.
+ if (added + updated > 0 || held.damagedMonths.size > 0) {
+ await this.ledger.save(merged.records, new Set([...merged.months, ...held.damagedMonths]));
+ }
+ }
+ await resolver.close();
+ const consentLogDamaged = resolver.consentLogDamaged;
+
+ let snapshots = 0;
+ for (const [repositoryId, root] of live) {
+ snapshots += await this.snapshots.execute(repositoryId, root);
+ }
+
+ // Last, so a crash before it only means the same lines are read again.
+ if (movedPaths.length > 0) await this.ledger.savePositions(reading.positions);
+
+ return {
+ refused: false,
+ filesRead,
+ added,
+ updated,
+ unrecognised: reading.unrecognised.length,
+ skippedLedgerLines,
+ snapshots,
+ notStored,
+ consentLogDamaged,
+ oldestTranscriptAt: reading.oldestTranscriptAt,
+ };
+ }
+
+ /** Every tool's reading as one. Each tool hands back its own files' positions only, laid over
+ * the ones held, so a file no longer listed keeps its place and no tool undoes another's. */
+ private async readAll(
+ before: ReadonlyMap,
+ coveredFrom: number
+ ): Promise {
+ const readings = await Promise.all(
+ this.readers.map((reader) => reader.execute(before, coveredFrom))
+ );
+ const oldest = readings.flatMap((reading) => reading.oldestTranscriptAt ?? []).sort();
+ return {
+ records: readings.flatMap((reading) => reading.records),
+ positions: new Map([...before, ...readings.flatMap((reading) => [...reading.positions])]),
+ unrecognised: readings.flatMap((reading) => reading.unrecognised),
+ oldestTranscriptAt: oldest[0] ?? null,
+ skipped: new Set(readings.flatMap((reading) => [...reading.skipped])),
+ };
+ }
+
+ /** A call with no time has no month to be kept in, and one with no directory has no
+ * repository to be tied to. */
+ private async outcomeOf(record: UsageRecord, resolver: ResolutionRun): Promise {
+ if (monthOf(record.at) === null) return { skipped: "undated" };
+ if (record.cwd === null) return { skipped: "no-cwd" };
+ return resolver.resolve(record.cwd, record.at);
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/person-identity-use-case.ts b/cli/src/contexts/telemetry/application/person-identity-use-case.ts
deleted file mode 100644
index 4971d903e..000000000
--- a/cli/src/contexts/telemetry/application/person-identity-use-case.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import {
- EmptyDisplayNameError,
- EmptyIdentifierError,
- IdentityRequiredToLinkError,
- UnreadableIdentityFileError,
-} from "../../../kernel/errors.js";
-import type { PersonIdentity } from "../domain/ports/person-identity-reader.js";
-import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js";
-
-export interface PersonIdentityStatusResult {
- readonly filePath: string;
- readonly identity: PersonIdentity | null;
-}
-
-export interface PersonIdentityUseResult {
- readonly filePath: string;
- readonly identity: PersonIdentity;
- /** How this machine came to carry the identifier it now carries. Three values, not two
- * booleans: one this machine minted is not the same fact as one carried here from another. */
- readonly outcome: "minted" | "adopted" | "unchanged";
- /** The identifier this replaced โ absent both when nothing was declared yet and when the same
- * one was already in effect. Records already written keep the identifier they carry. */
- readonly replacedPersonId?: string;
- /** The display name this call attached, when one was asked for. Absent when none was โ
- * never `""`, which would read as a name someone chose to be empty. */
- readonly displayNameSet?: string;
-}
-
-export interface PersonIdentityOffResult {
- readonly filePath: string;
- /** `false` when there was nothing to withdraw. */
- readonly removed: boolean;
- /** `true` when the file existed but could not be read back, and was removed anyway โ `off`
- * must work exactly when a damaged file would otherwise leave a person unable to withdraw. */
- readonly discardedDamaged: boolean;
- /** How many identifiers `alsoMe` carried at withdrawal โ `off` removes the whole declaration.
- * `0` both when none were added and when a damaged file hid how many there were. */
- readonly addedIdentifiersRemoved: number;
-}
-
-export interface PersonIdentityLinkResult {
- readonly filePath: string;
- readonly personId: string;
- readonly identity: string;
- /** `true` when `identity` already resolved to this same person before this call - a caller
- * that always links first, then reports, must tell a no-op apart from a fresh write. */
- readonly alreadyListed: boolean;
-}
-
-export interface PersonIdentityUnlinkResult {
- readonly filePath: string;
- readonly identity: string;
- /** `false` when `identity` was never listed at all - reported as nothing to remove,
- * never as a failure. */
- readonly removed: boolean;
-}
-
-/**
- * Every verb acts on the one file declaring who this machine's user is: `use` mints an
- * identifier or takes one minted elsewhere, so a person reads as one across machines;
- * `link`/`unlink` carry identifiers chosen elsewhere on `alsoMe`; `off` withdraws the whole
- * file. Neither `use` nor `link` can verify that the person running it is who they claim.
- */
-export class PersonIdentityUseCase {
- constructor(private readonly store: PersonIdentityStore) {}
-
- async status(): Promise {
- const filePath = this.store.filePath;
- const identity = await this.store.readStrict();
- return {
- filePath,
- identity,
- };
- }
-
- /** The one door to "which identifier am I": `identifier` absent mints, present adopts โ the
- * same question with and without an answer in hand, kept apart on disk by `origin`. */
- async use(options: {
- identifier?: string;
- displayName?: string;
- }): Promise {
- if (options.identifier !== undefined && options.identifier.trim() === "") {
- throw new EmptyIdentifierError("use");
- }
- if (options.displayName !== undefined && options.displayName.trim() === "") {
- throw new EmptyDisplayNameError();
- }
- const settled = await this.settleIdentifier(options.identifier);
- const identity =
- options.displayName === undefined
- ? settled.identity
- : await this.store.setDisplayName(settled.identity, options.displayName);
- return {
- filePath: this.store.filePath,
- identity,
- outcome: settled.outcome,
- ...(settled.replacedPersonId === undefined
- ? {}
- : { replacedPersonId: settled.replacedPersonId }),
- ...(options.displayName === undefined ? {} : { displayNameSet: options.displayName }),
- };
- }
-
- /** Split from the display name because that is an independent decision: folding both into one
- * body would make a rename look like a change of identity. */
- private async settleIdentifier(identifier?: string): Promise<{
- identity: PersonIdentity;
- outcome: PersonIdentityUseResult["outcome"];
- replacedPersonId?: string;
- }> {
- const current = await this.store.readStrict();
- if (identifier === undefined) {
- if (current !== null) return { identity: current, outcome: "unchanged" };
- return { identity: await this.store.mint(), outcome: "minted" };
- }
- if (current !== null && current.personId === identifier) {
- return { identity: current, outcome: "unchanged" };
- }
- const identity = await this.store.adopt(identifier);
- return {
- identity,
- outcome: "adopted",
- ...(current === null ? {} : { replacedPersonId: current.personId }),
- };
- }
-
- async link(identity: string): Promise {
- if (identity.trim() === "") throw new EmptyIdentifierError("link");
- const person = await this.store.readStrict();
- if (person === null) throw new IdentityRequiredToLinkError();
- const alreadyListed = identity === person.personId || person.alsoMe.includes(identity);
- if (!alreadyListed) await this.store.addAlsoMe(identity);
- return { filePath: this.store.filePath, personId: person.personId, identity, alreadyListed };
- }
-
- async unlink(identity: string): Promise {
- const person = await this.store.readStrict();
- const removed = person?.alsoMe.includes(identity) ?? false;
- if (removed) await this.store.removeAlsoMe(identity);
- return { filePath: this.store.filePath, identity, removed };
- }
-
- /** The one verb allowed to swallow `readStrict()`'s throw: `off` is how a person gets out, and
- * a file too damaged to parse is exactly the moment withdrawing must still work. */
- async off(): Promise {
- const filePath = this.store.filePath;
- const { existing, discardedDamaged } = await this.readForWithdrawal();
- const addedIdentifiersRemoved = existing?.alsoMe.length ?? 0;
- // Always asks the store, never decides from the read above: a file holding an empty
- // `person_id` reads as "nobody chose" and would be left on disk.
- const removed = await this.store.forget(filePath);
- return { filePath, removed, discardedDamaged, addedIdentifiersRemoved };
- }
-
- private async readForWithdrawal(): Promise<{
- existing: PersonIdentity | null;
- discardedDamaged: boolean;
- }> {
- try {
- return { existing: await this.store.readStrict(), discardedDamaged: false };
- } catch (error) {
- if (error instanceof UnreadableIdentityFileError) {
- return { existing: null, discardedDamaged: true };
- }
- throw error;
- }
- }
-}
diff --git a/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts b/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts
deleted file mode 100644
index 10f655a3c..000000000
--- a/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts
+++ /dev/null
@@ -1,502 +0,0 @@
-import { errorMessage } from "../../../kernel/describe-error.js";
-import type {
- TelemetryLocalReadDeclared,
- TelemetryLocalReadUnsupported,
-} from "../../../kernel/measurement.js";
-import type { Logger } from "../../../kernel/ports/logger.js";
-import type { VersionReader } from "../../../kernel/ports/version-reader.js";
-import { AI_TOOL_IDS, type AiToolId } from "../../../kernel/tool.js";
-import { getAiToolConfig, journalHostToAiToolId } from "../../tools/domain/registry.js";
-import type {
- PersonIdentity,
- PersonIdentityReader,
-} from "../domain/ports/person-identity-reader.js";
-import type { RunJournalReader } from "../domain/ports/run-journal-reader.js";
-import type {
- LocalCostCandidateRecord,
- LocalCostReadResult,
- SessionCostReader,
-} from "../domain/ports/session-cost-reader.js";
-import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js";
-import type { TelemetrySink } from "../domain/ports/telemetry-sink.js";
-import { resolveSessionProject, type SessionProject } from "../domain/session-project.js";
-import {
- attributeMoment,
- buildStepIntervals,
- type StepInterval,
-} from "../domain/step-attribution.js";
-import { SINK_SCHEMA_VERSION, type TelemetrySinkRecord } from "../domain/telemetry-sink-record.js";
-import {
- DEFAULT_TELEMETRY_SINK_RETENTION_DAYS,
- decideTelemetrySinkRetention,
-} from "../domain/telemetry-sink-retention.js";
-
-/** Six answers, and only `empty` may ever be printed as a zero โ there the zero is the
- * measurement. `not-found` is an observation, `not-asked` a decision not to look, `unreadable` a
- * failure: collapsing any of them into `empty` is how a session nobody measured reads as free. */
-export type LocalCostToolStatus =
- | "found"
- | "empty"
- | "not-found"
- | "unreadable"
- | "not-covered"
- | "not-asked";
-
-export interface LocalCostToolReport {
- readonly tool: AiToolId;
- readonly status: LocalCostToolStatus;
- /** Records the reader returned, before dedup โ this is what makes "found" and "empty"
- * distinguishable from each other, independent of how many were new. */
- readonly recordsFound: number;
- /** Records newly appended to the sink; a re-read of an already-stored session can be
- * `status: "found"` with `recordsStored: 0`. */
- readonly recordsStored: number;
- /** Why this tool is not covered, or what a covered one's figures cannot yet be used for โ both
- * from the declaration. On `unreadable`, what the reader itself said. */
- readonly reason?: string;
- /** Sessions this tool's reader threw on. Separate from `status` because a sweep can read
- * nineteen and fail the twentieth: the figures are real, so the status stays `found`. */
- readonly sessionsFailed: number;
- /** What the last failed session's reader said, when any failed. */
- readonly failureReason?: string;
-}
-
-export interface ReadLocalCostOptions {
- /** One session by name. Absent reads every session the run journal knows about โ the
- * only route a person has, since nothing tells them a session identifier. */
- readonly sessionId?: string;
- readonly at?: Date;
- /** Where to look for `.aidd/config.json` when asking whether the project switch is on.
- * Required, not defaulted: this is the one route left that writes the sink. */
- readonly projectRoot: string;
- /** Passed through to the same refusal check the switch itself honours
- * (`AIDD_TELEMETRY=0`), rather than read from `process.env` here. */
- readonly env: NodeJS.ProcessEnv;
-}
-
-/** What every candidate gets stamped with: facts about where a record came from, never about the
- * record itself. `intervals` and `project` are per-session, `person` per-sweep. */
-interface LocalReadAttribution {
- readonly intervals: readonly StepInterval[];
- readonly project: SessionProject | null;
- readonly person: PersonIdentity | null;
-}
-
-/** What one session's read produced. `sessionId` is on the report because a sweep answers
- * about several and a caller has to be able to tell them apart. */
-export interface LocalCostSessionReport {
- readonly sessionId: string;
- readonly toolReports: readonly LocalCostToolReport[];
-}
-
-export interface ReadLocalCostResult {
- readonly sessions: readonly LocalCostSessionReport[];
- /** Every tool's answer across every session read, so a caller sees one line per tool
- * rather than one per tool per session. */
- readonly toolReports: readonly LocalCostToolReport[];
- /** Present only when the sweep refused to run at all. `sessions` and `toolReports` are empty
- * then, and an empty sweep must never be told apart from a refusal by inference. */
- readonly refusedReason?: string;
-}
-
-function isPresent(value: string | undefined): value is string {
- return value !== undefined;
-}
-
-/** Already-stored records for this session, keyed on `turn_id`; one carrying none is never
- * indexed. Mutable on purpose, so two candidates for one turn in a batch match each other. */
-function groupByTurnId(
- records: readonly TelemetrySinkRecord[]
-): Map {
- const groups = new Map();
- for (const record of records) {
- if (record.turn_id === undefined) continue;
- const bucket = groups.get(record.turn_id);
- if (bucket) bucket.push(record);
- else groups.set(record.turn_id, [record]);
- }
- return groups;
-}
-
-function indexStoredRecord(
- groups: Map,
- record: TelemetrySinkRecord
-): void {
- if (record.turn_id === undefined) return;
- const bucket = groups.get(record.turn_id);
- if (bucket) bucket.push(record);
- else groups.set(record.turn_id, [record]);
-}
-
-const LOCAL_READ_TURN_COUNTER_KEYS = [
- "input_tokens",
- "output_tokens",
- "cache_read_tokens",
- "cache_creation_tokens",
-] as const;
-
-/** How much of a turn a record accounts for โ used only to find the largest of several
- * still-open readings of it, never stored, never itself summed into a total. */
-function counterWeight(record: TelemetrySinkRecord): number {
- return LOCAL_READ_TURN_COUNTER_KEYS.reduce((sum, key) => sum + (record[key] ?? 0), 0);
-}
-
-/** Whether `candidate` improves on `stored`: every counter at least as large, one strictly
- * larger. The sink keeps the larger reading rather than let a figure fall back silently. */
-function strictlyImprovesOn(
- stored: TelemetrySinkRecord,
- candidate: LocalCostCandidateRecord
-): boolean {
- let improved = false;
- for (const key of LOCAL_READ_TURN_COUNTER_KEYS) {
- const before = stored[key];
- const after = candidate[key];
- if (before === undefined) {
- if (after !== undefined) improved = true;
- continue;
- }
- if (after === undefined || after < before) return false;
- if (after > before) improved = true;
- }
- return improved;
-}
-
-/** The strongest answer a tool gave anywhere in the sweep. One session read and another failed
- * reports `found` โ those figures are real โ and `sessionsFailed` carries the failure. */
-const STATUS_RANK: readonly LocalCostToolStatus[] = [
- "found",
- "unreadable",
- "empty",
- "not-found",
- "not-covered",
- // Weakest on purpose: one session where this tool was never asked must never outrank
- // another where it actually answered.
- "not-asked",
-];
-
-function strongestOf(tool: AiToolId, reports: readonly LocalCostToolReport[]): LocalCostToolReport {
- // The seed for a tool with no report at all: `not-asked` is what "nothing looked at it" means,
- // where `not-found` would report an observation never made.
- const nothingKnown: LocalCostToolReport = {
- tool,
- status: "not-asked",
- recordsFound: 0,
- recordsStored: 0,
- sessionsFailed: 0,
- };
- return reports.reduce(
- (strongest, report) =>
- STATUS_RANK.indexOf(report.status) < STATUS_RANK.indexOf(strongest.status)
- ? report
- : strongest,
- reports[0] ?? nothingKnown
- );
-}
-
-function mergeOneTool(
- tool: AiToolId,
- sessions: readonly LocalCostSessionReport[]
-): LocalCostToolReport {
- const reports = sessions.flatMap((session) =>
- session.toolReports.filter((report) => report.tool === tool)
- );
- const failures = reports
- .map((report) => report.failureReason)
- .filter((reason): reason is string => reason !== undefined);
- return {
- ...strongestOf(tool, reports),
- recordsFound: reports.reduce((sum, report) => sum + report.recordsFound, 0),
- recordsStored: reports.reduce((sum, report) => sum + report.recordsStored, 0),
- sessionsFailed: failures.length,
- ...(failures.length === 0 ? {} : { failureReason: failures[failures.length - 1] }),
- };
-}
-
-/** Nothing here can read this tool at all, with the reason its declaration gives. */
-function notCovered(tool: AiToolId, localRead: TelemetryLocalReadUnsupported): LocalCostToolReport {
- return {
- tool,
- status: "not-covered",
- recordsFound: 0,
- recordsStored: 0,
- sessionsFailed: 0,
- reason: localRead.reason,
- };
-}
-
-/** This tool's reader was never run, because the journal named another tool. Carries no
- * `reason`: nothing is wrong and nothing was measured, so the status is the whole fact. */
-function notAsked(tool: AiToolId): LocalCostToolReport {
- return { tool, status: "not-asked", recordsFound: 0, recordsStored: 0, sessionsFailed: 0 };
-}
-
-/** Its reader failed, so nothing is known about it and something is wrong โ distinct from
- * `not-found`, where nothing is known and nothing is wrong. */
-function unreadable(tool: AiToolId, failure: string): LocalCostToolReport {
- return {
- tool,
- status: "unreadable",
- recordsFound: 0,
- recordsStored: 0,
- sessionsFailed: 1,
- reason: failure,
- failureReason: failure,
- };
-}
-
-function mergeToolReports(
- sessions: readonly LocalCostSessionReport[]
-): readonly LocalCostToolReport[] {
- return AI_TOOL_IDS.map((tool) => mergeOneTool(tool, sessions));
-}
-
-const REFUSED_REASON =
- "measurement is refused โ AIDD_TELEMETRY=0 or the project switch is off; nothing read, " +
- "nothing stored";
-
-/** Reads what every locally-readable tool's own files hold for one session, normalises it into
- * the stored shape, and appends what is not already there. Which tools are readable is each
- * tool's own declaration, read through the registry โ this class names no tool. */
-export class ReadLocalCostUseCase {
- constructor(
- private readonly sink: TelemetrySink,
- private readonly readers: ReadonlyMap,
- private readonly runJournalReader: RunJournalReader,
- private readonly personIdentityReader: PersonIdentityReader,
- private readonly telemetryEvidenceReader: TelemetryEvidenceReader,
- /** The CLI's own version, stamped on every record this sweep stores. Optional only so a
- * caller exercising another concern need not invent one - absent, the field is omitted
- * from what gets stored, never guessed at. */
- private readonly versionReader?: VersionReader,
- /** Only the retention prune below writes here, and only to warn. Optional so a caller that
- * does not care about housekeeping warnings need not invent a logger. */
- private readonly logger: Logger = { debug() {}, info() {}, warn() {} },
- private readonly retentionDays: number = DEFAULT_TELEMETRY_SINK_RETENTION_DAYS
- ) {}
-
- async execute(options: ReadLocalCostOptions): Promise {
- // Re-checked here, since this is the sink's one remaining writer: a refusal enforced only by
- // the hook never writing a journal does not hold against `--session `, which never reads
- // one. Before any reader runs, so a refused sweep touches neither the sink nor a tool's files.
- if (
- !(await this.telemetryEvidenceReader.isTelemetryEnabled(options.projectRoot, options.env))
- ) {
- // Told once, by the caller's own display layer, not here too: `this.logger` exists for
- // housekeeping the figures themselves never surface.
- return { sessions: [], toolReports: mergeToolReports([]), refusedReason: REFUSED_REASON };
- }
- const at = options.at ?? new Date();
- const sessionIds =
- options.sessionId === undefined ? await this.journalledSessionIds() : [options.sessionId];
- // Resolved once for the whole sweep, not per session: this is a fact about the machine
- // this process is running on, not about any one session it reads.
- const person = await this.personIdentityReader.read();
- const sessions: LocalCostSessionReport[] = [];
- for (const sessionId of sessionIds) {
- sessions.push({ sessionId, toolReports: await this.readOneSession(sessionId, at, person) });
- }
- // A sweep prunes; a single named session does not: `report` catches sessions up one at a
- // time, and a command asked a question must not delete day files as a side effect.
- if (options.sessionId === undefined) await this.pruneOldDayFiles();
- return { sessions, toolReports: mergeToolReports(sessions) };
- }
-
- /**
- * Keeps the sink inside its retention window, once per sweep โ the unit a person invokes.
- * Every failure warns per file: housekeeping must not cost the figures this sweep just
- * stored, and one undeletable file must not spare every older one behind it.
- */
- private async pruneOldDayFiles(): Promise {
- let prune: readonly string[];
- try {
- prune = decideTelemetrySinkRetention(
- await this.sink.listDayFiles(),
- this.retentionDays
- ).prune;
- } catch (error) {
- this.logger.warn(`telemetry read: retention prune failed - ${errorMessage(error)}`);
- return;
- }
- for (const fileName of prune) {
- try {
- await this.sink.deleteDayFile(this.sink.rootDir, fileName);
- } catch (error) {
- this.logger.warn(`telemetry read: could not delete ${fileName} - ${errorMessage(error)}`);
- }
- }
- }
-
- /** Every session the journal names, oldest file first. A person has no other way to
- * learn a session identifier, and the journal has recorded every one of them. */
- private async journalledSessionIds(): Promise {
- const journals = await this.runJournalReader.list();
- const ids = journals.map((journal) => journal.session?.vendor_id).filter(isPresent);
- return [...new Set(ids)];
- }
-
- private async readOneSession(
- sessionId: string,
- at: Date,
- person: PersonIdentity | null
- ): Promise {
- // Read once per session, never per tool: every reader's candidates join the same journal. No
- // journal yields empty intervals and a `null` project, so candidates fall through to
- // unattributed rather than the read failing, and the project is never re-derived from cwd.
- const journal = await this.runJournalReader.read(sessionId);
- const attribution: LocalReadAttribution = {
- intervals: journal ? buildStepIntervals(journal) : [],
- project: resolveSessionProject(journal),
- person,
- };
- // Only the tool whose session this is: the journal's `session_start` names the host that
- // wrote it, so asking the others is useless work one of them pays for in process spawns โ the
- // OpenCode reader shells out to its binary and waits. Fan out only when the journal names no
- // tool, where the tool is genuinely unknown.
- const host = journal?.session?.tool;
- const namedTool = host === undefined ? null : journalHostToAiToolId(host);
- const toolReports: LocalCostToolReport[] = [];
- for (const tool of AI_TOOL_IDS) {
- toolReports.push(await this.answerFor(tool, namedTool, sessionId, at, attribution));
- }
- return toolReports;
- }
-
- /** What this tool has to say about this session. Coverage first, always: "nothing here can read
- * this tool" is true of every session, and it carries the declaration's own reason. */
- private async answerFor(
- tool: AiToolId,
- namedTool: AiToolId | null,
- sessionId: string,
- at: Date,
- attribution: LocalReadAttribution
- ): Promise {
- const localRead = getAiToolConfig(tool).telemetryLocalRead;
- if (localRead.kind !== "declared") return notCovered(tool, localRead);
- if (namedTool !== null && tool !== namedTool) return notAsked(tool);
- return this.readOneTool(tool, localRead, sessionId, at, attribution);
- }
-
- private async readOneTool(
- tool: AiToolId,
- localRead: TelemetryLocalReadDeclared,
- sessionId: string,
- at: Date,
- attribution: LocalReadAttribution
- ): Promise {
- const attempt = await this.attemptRead(tool, sessionId);
- if ("failure" in attempt) return unreadable(tool, attempt.failure);
- const candidates = attempt.records;
- const recordsStored = await this.storeNewCandidates(
- tool,
- sessionId,
- candidates,
- at,
- attribution
- );
- return {
- tool,
- status: candidates.length > 0 ? "found" : attempt.sessionFound ? "empty" : "not-found",
- recordsFound: candidates.length,
- recordsStored,
- sessionsFailed: 0,
- ...(localRead.limitation !== undefined ? { reason: localRead.limitation } : {}),
- };
- }
-
- /** The one place this use case catches: a fan-out over independent sources, where one reader
- * failing must not cost every other tool's figures, nor every other session's. */
- private async attemptRead(
- tool: AiToolId,
- sessionId: string
- ): Promise {
- const reader = this.readers.get(tool);
- if (!reader) return { records: [], sessionFound: false };
- try {
- return await reader.read(sessionId);
- } catch (error) {
- return { failure: error instanceof Error ? error.message : String(error) };
- }
- }
-
- /** Matches on `turn_id` alone, never a hash of the line: the tool's own file keeps growing as
- * the same record is read again. A stored turn is dropped unless a `request` local-read record
- * strictly improves on it, which appends a second line for `collapseSupersededTurns` to
- * reconcile. A correction is a larger counter, never a field the stored record lacks. */
- private async storeNewCandidates(
- tool: AiToolId,
- sessionId: string,
- candidates: readonly LocalCostCandidateRecord[],
- at: Date,
- attribution: LocalReadAttribution
- ): Promise {
- if (candidates.length === 0) return 0;
- const byTurnId = groupByTurnId(await this.sink.readRecordsForVendor(sessionId));
- let stored = 0;
- for (const candidate of candidates) {
- const prior = candidate.turn_id === undefined ? undefined : byTurnId.get(candidate.turn_id);
- if (prior && !this.isLocalReadTurnCorrection(candidate, prior)) continue;
- const record = this.stampProvenanceAndTool(tool, candidate, attribution);
- await this.sink.appendRecord(record, at);
- indexStoredRecord(byTurnId, record);
- stored++;
- }
- return stored;
- }
-
- /** Never for a `kind: "session"` record: Copilot's shutdown total shares this match but is a
- * one-shot cumulative figure, which a re-read would start doubling. Otherwise only a strict
- * improvement on the largest stored, never gated on `turn_end` โ a larger candidate is itself
- * proof the stored reading was not final. */
- private isLocalReadTurnCorrection(
- candidate: LocalCostCandidateRecord,
- prior: readonly TelemetrySinkRecord[]
- ): boolean {
- if (candidate.kind !== "request") return false;
- const priorReads = prior.filter((r) => r.kind === "request" && r.provenance === "local-read");
- if (priorReads.length === 0) return false;
- const largest = priorReads.reduce((best, r) =>
- counterWeight(r) > counterWeight(best) ? r : best
- );
- return strictlyImprovesOn(largest, candidate);
- }
-
- // The caller asked this tool's reader by name โ that is the fact this stamps, never
- // inferred from the candidate itself, which the reader's contract forbids it naming.
- private stampProvenanceAndTool(
- tool: AiToolId,
- candidate: LocalCostCandidateRecord,
- { intervals, project, person }: LocalReadAttribution
- ): TelemetrySinkRecord {
- return {
- ...candidate,
- sink_schema_version: SINK_SCHEMA_VERSION,
- provenance: "local-read",
- tool,
- ...this.resolveStepAttribution(candidate, intervals),
- ...(project === null
- ? {}
- : { project_id: project.projectId, project_field: project.projectField }),
- ...(person === null ? {} : { person_id: person.personId }),
- ...(person?.displayName === undefined ? {} : { person_display_name: person.displayName }),
- ...(this.versionReader === undefined ? {} : { cli_version: this.versionReader.get() }),
- };
- }
-
- // Where the candidate carries `step`, the tool stated it directly โ exact, never second-guessed
- // by an interval, which is only an inference. A candidate with no moment, or one earlier than
- // every interval, comes back unattributed rather than folded into the nearest step.
- private resolveStepAttribution(
- candidate: LocalCostCandidateRecord,
- intervals: readonly StepInterval[]
- ): Pick {
- if (candidate.step !== undefined) {
- return {
- step_attribution: "tool-stated",
- step: candidate.step,
- step_plugin: candidate.step_plugin,
- };
- }
- const attribution = attributeMoment(intervals, candidate.event_timestamp);
- return { step_attribution: attribution.source, step: attribution.step, step_plugin: undefined };
- }
-}
diff --git a/cli/src/contexts/telemetry/application/read-usage-use-case.ts b/cli/src/contexts/telemetry/application/read-usage-use-case.ts
new file mode 100644
index 000000000..28ee5d1c6
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/read-usage-use-case.ts
@@ -0,0 +1,61 @@
+import type { TranscriptSource } from "../domain/ports/transcript-source.js";
+import type { TranscriptPosition } from "../domain/transcript-position.js";
+import { foldUsage } from "../domain/usage-fold.js";
+import type { UsageRecord } from "../domain/usage-record.js";
+
+/** One tool's format: the billed calls one transcript line holds, and the shapes it did not
+ * recognise. */
+export type ReadUsageLine = (line: string) => {
+ readonly records: readonly UsageRecord[];
+ readonly unrecognised: readonly string[];
+};
+
+export interface UsageReading {
+ /** One record per billed call, folded across every file read. */
+ readonly records: readonly UsageRecord[];
+ /** Where each of this tool's files now stands, to hand back next time. */
+ readonly positions: ReadonlyMap;
+ /** Shapes seen and not recognised, each prefixed with its file. */
+ readonly unrecognised: readonly string[];
+ /** When the oldest transcript on disk was last written, `null` when there is none. */
+ readonly oldestTranscriptAt: string | null;
+ /** The files not read because they were last written before `writtenFrom`. */
+ readonly skipped: ReadonlySet;
+}
+
+/** Reads one tool's usage: its transcripts, from `source`, line by line in its format. */
+export class ReadUsageUseCase {
+ constructor(
+ private readonly source: TranscriptSource,
+ private readonly readLine: ReadUsageLine
+ ) {}
+
+ /** `writtenFrom`: files last written before it are not read; none, by default. */
+ async execute(
+ since: ReadonlyMap,
+ writtenFrom = Number.NEGATIVE_INFINITY
+ ): Promise {
+ const candidates: UsageRecord[] = [];
+ const skipped = new Set();
+ const unrecognised: string[] = [];
+ const positions = new Map();
+ const paths = await this.source.list();
+ for (const path of paths) {
+ const read = await this.source.read(path, since.get(path) ?? null, writtenFrom);
+ positions.set(path, read.position);
+ if (read.skipped) skipped.add(path);
+ for (const line of read.lines) {
+ const outcome = this.readLine(line);
+ candidates.push(...outcome.records);
+ unrecognised.push(...outcome.unrecognised.map((reason) => `${path}: ${reason}`));
+ }
+ }
+ return {
+ records: foldUsage(candidates),
+ positions,
+ unrecognised,
+ oldestTranscriptAt: await this.source.oldestModified(paths),
+ skipped,
+ };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/report-cost-use-case.ts b/cli/src/contexts/telemetry/application/report-cost-use-case.ts
deleted file mode 100644
index f21caef86..000000000
--- a/cli/src/contexts/telemetry/application/report-cost-use-case.ts
+++ /dev/null
@@ -1,447 +0,0 @@
-import { UnreadableIdentityFileError } from "../../../kernel/errors.js";
-import type { Logger } from "../../../kernel/ports/logger.js";
-import { AI_TOOL_IDS } from "../../../kernel/tool.js";
-import { getAiToolConfig } from "../../tools/domain/registry.js";
-import {
- buildCostReport,
- type CostReport,
- type CostReportFilters,
- type CostReportInput,
- type CostReportSessionJournal,
- type CostReportToolCapability,
- type CostReportToolDeclaration,
- type PersonIdentityUnusableCause,
-} from "../domain/cost-report.js";
-import { buildFlowIntervals } from "../domain/flow-attribution.js";
-import type { PersonIdentity } from "../domain/ports/person-identity-reader.js";
-import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js";
-import type { RunJournal, RunJournalReader } from "../domain/ports/run-journal-reader.js";
-import type { TaskBacklogReader } from "../domain/ports/task-backlog-reader.js";
-import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js";
-import type { TelemetrySink, TelemetrySinkPeriodRead } from "../domain/ports/telemetry-sink.js";
-import type { ResolvedReportPeriod } from "../domain/report-period.js";
-import {
- attributeMoment,
- buildStepIntervals,
- type StepInterval,
-} from "../domain/step-attribution.js";
-import { buildTaskIntervals } from "../domain/task-attribution.js";
-import {
- type TaskBacklogDeclaration,
- taskFolderPathFromIdentity,
-} from "../domain/task-backlog-link.js";
-import { type TaskIdentity, taskIdentityFromWrittenPath } from "../domain/task-identity.js";
-import type { TelemetrySinkRecord } from "../domain/telemetry-sink-record.js";
-import type { ReadLocalCostResult, ReadLocalCostUseCase } from "./read-local-cost-use-case.js";
-
-export interface ReportCostOptions {
- /** Already two absolute days, resolved once at the edge โ so nothing from here down reads a
- * clock, and the same options answer the same twice. */
- readonly period: ResolvedReportPeriod;
- /** Restrict to the sessions that wrote into this task. Absent reports the whole period. */
- readonly task?: TaskIdentity;
- /** Any of `project`, `step`, `model` and `tool` - each optional, composing with `task`
- * and each other by `and`. */
- readonly filters?: CostReportFilters;
- /** Where to look for `.aidd/config.json` when asking whether the project switch is on. */
- readonly projectRoot: string;
- /** Passed through to the same refusal check the switch itself honours (`AIDD_TELEMETRY=0`),
- * rather than read from `process.env` down in an adapter. */
- readonly env: NodeJS.ProcessEnv;
-}
-
-/** What each tool declares about being read at all, as data the pure report consumes โ so a
- * report prints the declaration's own reason rather than a zero, `limitation` included. */
-function declaredTools(): readonly CostReportToolDeclaration[] {
- return AI_TOOL_IDS.map((tool) => {
- const config = getAiToolConfig(tool);
- const localRead = config.telemetryLocalRead;
- const capability: CostReportToolCapability = {
- localRead: localRead.kind === "declared" ? localRead.supplies : null,
- // No tool declares an export route any more, so nothing could ever supply this. Kept as
- // `null` rather than a type change rippling through the `--json` contract.
- export: null,
- journalAttributable: config.telemetryJournalHost !== undefined,
- taskAttributable: config.telemetryTaskAttributable,
- };
- if (localRead.kind === "declared") {
- return {
- tool,
- coverage: "covered" as const,
- ...(localRead.limitation === undefined ? {} : { reason: localRead.limitation }),
- capability,
- };
- }
- return {
- tool,
- coverage: "not-covered" as const,
- ...(localRead.kind === "unsupported" ? { reason: localRead.reason } : {}),
- capability,
- };
- });
-}
-
-/** The first and last moment a journal's own lines carry โ every line kind, since the question is
- * "was this journal open then". Not capped at the period's end: the sink returns no record past
- * it, so a clock-skewed line can widen the span but never pull a record in. */
-const LAST_MILLISECOND_OF_A_SECOND = 999;
-
-function witnessedSpan(journal: RunJournal): { fromMs: number; toMs: number } | undefined {
- const moments = [
- ...journal.boundaries,
- ...journal.taskDeclarations,
- ...journal.filesWritten,
- ...(journal.session ? [journal.session] : []),
- ]
- .map((line) => Date.parse(line.at))
- .filter((atMs) => !Number.isNaN(atMs));
- if (moments.length === 0) return undefined;
- // The end is the end of the second the last line names: the writing hook strips milliseconds
- // from a journal moment while a record carries them, so comparing the two as instants would
- // refuse a record that landed inside the very second the journal last wrote.
- return {
- fromMs: Math.min(...moments),
- toMs: Math.max(...moments) + LAST_MILLISECOND_OF_A_SECOND,
- };
-}
-
-function toSessionJournal(
- journal: RunJournal,
- periodEndMs: number
-): CostReportSessionJournal | null {
- if (!journal.session) return null;
- const span = witnessedSpan(journal);
- return {
- vendorId: journal.session.vendor_id,
- tool: journal.session.tool,
- ...(journal.session.project_id === undefined ? {} : { projectId: journal.session.project_id }),
- writtenPaths: journal.filesWritten.map((written) => written.path),
- taskIntervals: buildTaskIntervals(journal, periodEndMs),
- flowIntervals: buildFlowIntervals(journal, periodEndMs),
- ...(span === undefined ? {} : { witnessed: span }),
- };
-}
-
-/** Every distinct task identity this period's journals could ever key `by_task` on. Each is
- * resolved to its folder's declaration exactly once, never once per record. */
-function distinctTaskIdentities(
- journals: readonly RunJournal[],
- periodEndMs: number
-): readonly TaskIdentity[] {
- const seen = new Set();
- const identities: TaskIdentity[] = [];
- const remember = (identity: TaskIdentity | null): void => {
- if (identity === null || seen.has(identity)) return;
- seen.add(identity);
- identities.push(identity);
- };
- for (const journal of journals) {
- for (const interval of buildTaskIntervals(journal, periodEndMs)) {
- remember(taskIdentityFromWrittenPath(interval.path));
- }
- // Written paths too, not declared intervals alone: that folder can declare a backlog item,
- // and declarations alone would claim it declares none, from a lookup that never ran.
- for (const written of journal.filesWritten) {
- remember(taskIdentityFromWrittenPath(written.path));
- }
- }
- return identities;
-}
-
-/** One read per distinct task identity, through the port, never re-read per record. A reader
- * that throws is not this function's to catch: `TaskBacklogReader.read` promises it never does. */
-async function taskBacklogDeclarationsOf(
- reader: TaskBacklogReader,
- journals: readonly RunJournal[],
- periodEndMs: number
-): Promise> {
- const declarations = new Map();
- for (const identity of distinctTaskIdentities(journals, periodEndMs)) {
- declarations.set(identity, await reader.read(taskFolderPathFromIdentity(identity)));
- }
- return declarations;
-}
-
-const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
-
-/** The first moment no record in the period can reach: `toDay` itself runs through 23:59:59.999
- * UTC, so this is the *start* of the day after, never `toDay`'s own start โ which would cut off
- * a record legitimately timestamped later that day. */
-function periodEndMsOf(toDay: string): number {
- return Date.parse(`${toDay}T00:00:00Z`) + MILLISECONDS_PER_DAY;
-}
-
-interface PersonIdentityFields {
- readonly identity: PersonIdentity | null;
- readonly identityUnusableCause?: PersonIdentityUnusableCause;
-}
-
-/**
- * Never aborts the report over a damaged identity file: that is one dependency's own trouble,
- * and the figures must still come back whole. Names which of the two causes fired rather than
- * folding both into a boolean, and re-throws anything else rather than mislabel an unexpected
- * failure as a familiar-looking caveat.
- */
-async function personIdentityFields(store: PersonIdentityStore): Promise {
- try {
- const identity = await store.readStrict();
- return identity === null ? { identity: null, identityUnusableCause: "absent" } : { identity };
- } catch (error) {
- if (error instanceof UnreadableIdentityFileError) {
- return { identity: null, identityUnusableCause: "unreadable" };
- }
- throw error;
- }
-}
-
-/** `identity` and `identityUnusableCause` together, as `buildCostReport` wants them - pulled out
- * so `execute` reads as one shape assembled from its own reads. */
-function identityInputFields(
- fields: PersonIdentityFields
-): Pick {
- return {
- identity: fields.identity,
- ...(fields.identityUnusableCause === undefined
- ? {}
- : { identityUnusableCause: fields.identityUnusableCause }),
- };
-}
-
-/** Which skill each prompt opened, from the journal's own `step_start` lines. First wins:
- * several steps can open under one prompt, and a prompt names the step its work *began* in โ
- * taking the last would answer for the reasoning that produced an earlier step's output. */
-function promptToSkill(journal: RunJournal): ReadonlyMap {
- const byPrompt = new Map();
- for (const boundary of journal.boundaries) {
- if (boundary.type !== "step_start" || boundary.turn_id === undefined) continue;
- if (!byPrompt.has(boundary.turn_id)) byPrompt.set(boundary.turn_id, boundary.skill);
- }
- return byPrompt;
-}
-
-/** The step a record's own prompt opened. Outranks the interval, and says so: `prompt-matched`
- * is an identifier two sources agree on, where `journal-interval` infers from moments, so it
- * stays true when two tasks advance at once. Two tasks inside *one* prompt stay indivisible: a
- * billed amount cannot be split without inventing a ratio. */
-function matchOnPrompt(
- record: TelemetrySinkRecord,
- byPrompt: ReadonlyMap | undefined
-): { readonly source: "prompt-matched"; readonly step: string } | null {
- const step = journalNamedStep(record, byPrompt) ?? record.prompt_skill;
- return step === undefined ? null : { source: "prompt-matched", step };
-}
-
-/** What the run journal says the record's own prompt opened, asked first: the journal was
- * written by a hook the host itself fired, while `prompt_skill` is read back off a transcript
- * afterwards, so the reading with a witness wins. A session the journal never saw at all falls
- * through to the record's own. */
-function journalNamedStep(
- record: TelemetrySinkRecord,
- byPrompt: ReadonlyMap | undefined
-): string | undefined {
- if (record.prompt_id === undefined || byPrompt === undefined) return undefined;
- return byPrompt.get(record.prompt_id);
-}
-
-/** Every record's step, derived rather than trusted from disk: `step_attribution` is stored when
- * the record is read, frozen at whatever the rule answered then. `tool-stated` is left alone,
- * being witnessed rather than inferred, and so is a session this period's journals say nothing
- * about โ no interval to judge it against, and a blanker answer is worse than a stale one. */
-function withDerivedStep(
- records: readonly TelemetrySinkRecord[],
- journals: readonly RunJournal[]
-): readonly TelemetrySinkRecord[] {
- const bySession = new Map();
- const skillByPrompt = new Map>();
- for (const journal of journals) {
- if (!journal.session) continue;
- bySession.set(journal.session.vendor_id, buildStepIntervals(journal));
- skillByPrompt.set(journal.session.vendor_id, promptToSkill(journal));
- }
-
- return records.map((record) => {
- if (record.step_attribution === "tool-stated") return record;
- const intervals = bySession.get(record.vendor_id);
- if (intervals === undefined) return record;
-
- const matched = matchOnPrompt(record, skillByPrompt.get(record.vendor_id));
- const derived = matched ?? attributeMoment(intervals, record.event_timestamp);
- // Rebuilt rather than spread over: a record that carried a step from an earlier reading
- // must lose it when the journal no longer names one, and a spread would keep it.
- const { step: _step, step_plugin: _plugin, ...rest } = record;
- return {
- ...rest,
- step_attribution: derived.source,
- ...(derived.step === undefined ? {} : { step: derived.step }),
- };
- });
-}
-
-/** Every gathered read, folded into the one shape `buildCostReport` wants - kept on its own
- * so `execute` reads as "gather, then assemble," not a wall of field assignments. */
-function toReportInput(
- options: ReportCostOptions,
- read: Awaited>,
- journals: readonly RunJournal[],
- identity: PersonIdentityFields,
- measurementEnabled: boolean,
- taskBacklogDeclarations: ReadonlyMap
-): CostReportInput {
- const { fromDay, toDay } = options.period;
- const periodEndMs = periodEndMsOf(toDay);
- return {
- fromDay,
- toDay,
- records: withDerivedStep(read.records, journals),
- journals: journals
- .map((journal) => toSessionJournal(journal, periodEndMs))
- .filter((journal) => journal !== null),
- declaredTools: declaredTools(),
- undatedRecords: read.undated.length,
- unreadableLines: read.skippedLines,
- ...(options.task === undefined ? {} : { task: options.task }),
- ...(options.filters === undefined ? {} : { filters: options.filters }),
- knownValues: read.knownValues,
- measurementEnabled,
- taskBacklogDeclarations,
- ...identityInputFields(identity),
- };
-}
-
-/** Sessions holding at least one stored record a re-read could never be matched against: a
- * re-read reconciles on `turn_id`, and a record carrying none is never indexed, so re-reading
- * such a session would append its records a second time. A host that writes no such identifier
- * must not be silently doubled. */
-function sessionsWithAnUnmatchableRecord(
- stored: readonly TelemetrySinkRecord[]
-): ReadonlySet {
- const sessions = new Set();
- for (const record of stored) {
- if (record.turn_id === undefined) sessions.add(record.vendor_id);
- }
- return sessions;
-}
-
-/** Every session the journal names whose own `session_start` falls inside the period โ never
- * "the ones the sink has never seen", which freezes a session still running the moment its first
- * turn is stored. Re-reading is safe, the local read dedupes per `turn_id`; the period bound is
- * what stops a one-week report re-reading every session ever journalled. */
-function sessionsToCatchUp(
- stored: readonly TelemetrySinkRecord[],
- journals: readonly RunJournal[],
- fromMs: number,
- periodEndMs: number
-): readonly string[] {
- const unmatchable = sessionsWithAnUnmatchableRecord(stored);
- const missing: string[] = [];
- for (const journal of journals) {
- const session = journal.session;
- if (session === undefined || unmatchable.has(session.vendor_id)) continue;
- const atMs = Date.parse(session.at);
- // `periodEndMs` is the first instant *after* the period, which is why this is `>=` and not
- // `>`. Taken from `periodEndMsOf`, never recomputed here: an end computed from `toDay`'s own
- // start excludes the whole of `toDay`, which `--days N` always makes today.
- if (Number.isNaN(atMs) || atMs < fromMs || atMs >= periodEndMs) continue;
- missing.push(session.vendor_id);
- }
- return missing;
-}
-
-/**
- * Answers what a period, or one task inside it, cost. Orchestration only: the rules belong to
- * `domain/cost-report.ts`. It names no tool and computes no figure - in particular no amount,
- * since the rates live outside this repository and an amount is only ever reported where a
- * tool's own files already carried one.
- */
-export class ReportCostUseCase {
- constructor(
- private readonly sink: TelemetrySink,
- private readonly runJournalReader: RunJournalReader,
- private readonly personIdentityStore: PersonIdentityStore,
- private readonly telemetryEvidenceReader: TelemetryEvidenceReader,
- private readonly taskBacklogReader: TaskBacklogReader,
- /** Where `warnAboutFailures` says what a reader could not answer. */
- private readonly logger: Logger,
- /** Reads the sessions the sink has not caught up with yet, before the report is built.
- * Optional so a caller exercising the report's own rules need not wire it; absent, this
- * reports exactly what the sink already holds. */
- private readonly readLocalCost?: ReadLocalCostUseCase
- ) {}
-
- /** Whether the project switch is on right now - independent of the sink and the journal,
- * so gathered on its own rather than folded into either of their reads. */
- private async measurementEnabled(options: ReportCostOptions): Promise {
- return this.telemetryEvidenceReader.isTelemetryEnabled(options.projectRoot, options.env);
- }
-
- async execute(options: ReportCostOptions): Promise {
- const { fromDay, toDay } = options.period;
- const from = new Date(`${fromDay}T00:00:00Z`);
- const to = new Date(`${toDay}T00:00:00Z`);
- const periodEndMs = periodEndMsOf(toDay);
- // Every journal, not only the period's: a journal carries no date in its file name, and the
- // records it is joined to were already selected by their own moments.
- const journals = await this.runJournalReader.list();
- const read = await this.catchUp(
- await this.sink.readRecordsInPeriod(from, to),
- journals,
- options,
- { from, to, periodEndMs }
- );
- const identity = await personIdentityFields(this.personIdentityStore);
- const measurementEnabled = await this.measurementEnabled(options);
- const taskBacklogDeclarations = await taskBacklogDeclarationsOf(
- this.taskBacklogReader,
- journals,
- periodEndMsOf(toDay)
- );
-
- return buildCostReport(
- toReportInput(options, read, journals, identity, measurementEnabled, taskBacklogDeclarations)
- );
- }
-
- /** Reads whatever the sink has not caught up with, then asks it again. `ReadLocalCostUseCase`
- * refuses on its own when measurement is off, so this needs no second gate: a refusal stores
- * nothing and the report describes what was already there. */
- private async catchUp(
- read: TelemetrySinkPeriodRead,
- journals: readonly RunJournal[],
- options: ReportCostOptions,
- period: { from: Date; to: Date; periodEndMs: number }
- ): Promise {
- if (this.readLocalCost === undefined) return read;
- const missing = sessionsToCatchUp(
- read.records,
- journals,
- period.from.getTime(),
- period.periodEndMs
- );
- if (missing.length === 0) return read;
- for (const sessionId of missing) {
- this.warnAboutFailures(
- sessionId,
- await this.readLocalCost.execute({
- sessionId,
- projectRoot: options.projectRoot,
- env: options.env,
- })
- );
- }
- return this.sink.readRecordsInPeriod(period.from, period.to);
- }
-
- /** Says what a reader could not answer, since behind a report nobody sees the read's own
- * output: a period where every reader threw would otherwise print what a period with no spend
- * prints. Warnings go to stderr, so a `--json` caller's stdout stays one parseable object. */
- private warnAboutFailures(sessionId: string, result: ReadLocalCostResult): void {
- const say = this.logger.warn.bind(this.logger);
- for (const report of result.toolReports) {
- if (report.status !== "unreadable") continue;
- say(
- `telemetry report: ${report.tool} could not be read for session ${sessionId}` +
- `${report.failureReason === undefined ? "" : ` - ${report.failureReason}`}`
- );
- }
- }
-}
diff --git a/cli/src/contexts/telemetry/application/report/declared-bindings.ts b/cli/src/contexts/telemetry/application/report/declared-bindings.ts
new file mode 100644
index 000000000..4c9fa458b
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/report/declared-bindings.ts
@@ -0,0 +1,21 @@
+import type { BindingSnapshotStore } from "../../domain/ports/bindings/binding-snapshot-store.js";
+import type { SessionBindingStore } from "../../domain/ports/bindings/session-binding-store.js";
+import type { AttributionFacts } from "../../domain/report/attribution.js";
+
+/** Everything people declared about what their work belongs to, wherever each kind is kept:
+ * session declarations, the carries across a `/clear`, and the snapshots of branch
+ * declarations. Read whole, never narrowed to a period. */
+export class DeclaredBindings {
+ constructor(
+ private readonly sessions: SessionBindingStore,
+ private readonly snapshots: BindingSnapshotStore
+ ) {}
+
+ async read(): Promise {
+ return {
+ declarations: await this.sessions.declarations(),
+ carries: await this.sessions.carries(),
+ branches: await this.snapshots.history(),
+ };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/report/report-envelope.ts b/cli/src/contexts/telemetry/application/report/report-envelope.ts
new file mode 100644
index 000000000..d97771ca9
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/report/report-envelope.ts
@@ -0,0 +1,116 @@
+import type { UnattributedReason } from "../../domain/report/attribution.js";
+import type { Period } from "../../domain/report/period.js";
+import type { CounterSum, ReportAxis, ReportRow, Tally } from "../../domain/report/usage-report.js";
+import type { NotStoredReason } from "../../domain/repository-resolution.js";
+import type { ReportResult } from "./report-usage-use-case.js";
+
+/** A counter: the sum of the calls that reported it, and how many calls did not. A call that
+ * did not report a counter is left out of the sum, never counted as zero. */
+export interface EnvelopeCounter {
+ readonly tokens: number;
+ readonly unknown_records: number;
+}
+
+export interface EnvelopeTally {
+ readonly records: number;
+ readonly input: EnvelopeCounter;
+ readonly output: EnvelopeCounter;
+ readonly cache_read: EnvelopeCounter;
+ readonly cache_write: EnvelopeCounter;
+ /** The four counters added. `unknown_records` counts calls with any counter unknown. */
+ readonly total: EnvelopeCounter;
+}
+
+export interface EnvelopeRow extends EnvelopeTally {
+ /** `value`: a value of the axis, named by `key`. `unattributed`: work with no task, named
+ * by `reason`. `absent`: calls the axis has no value for (no person chosen, no model, no
+ * ticket). */
+ readonly kind: "value" | "unattributed" | "absent";
+ readonly key: string | null;
+ readonly reason: UnattributedReason | null;
+}
+
+/** What `aidd telemetry report --json` prints. Version 1.
+ *
+ * `rows` split the period along `axis`; the rows of every axis add up to the same `totals`,
+ * counter by counter. `unknown_records` is how many calls had at least one counter unknown.
+ * `coverage` says what the figures rest on: `files_read` and `unrecognised_shapes` are of
+ * this run alone, `records` of the period, `not_stored` counts calls this run read and left
+ * out by reason, `consent_log_damaged` is true while `ledger/consents.jsonl` cannot be trusted
+ * and nothing is stored, and `oldest_transcript_at` is when the oldest transcript still on disk was
+ * last written (`null` when there is none). Nothing here is a path, a working directory or
+ * a branch name.
+ *
+ * Under `AIDD_TELEMETRY=0` nothing is read and the envelope is `{ "version": 1, "refused":
+ * "AIDD_TELEMETRY=0" }` (`REFUSED_ENVELOPE`).
+ *
+ * A new field may appear in a version 1 envelope; a field is never renamed, retyped or removed
+ * without a new `version`. */
+export interface ReportEnvelope {
+ readonly version: 1;
+ readonly period: Period;
+ readonly axis: ReportAxis;
+ readonly rows: readonly EnvelopeRow[];
+ readonly totals: EnvelopeTally;
+ readonly unknown_records: number;
+ readonly coverage: {
+ readonly files_read: number;
+ readonly records: number;
+ readonly unrecognised_shapes: number;
+ readonly not_stored: Readonly>;
+ readonly consent_log_damaged: boolean;
+ readonly oldest_transcript_at: string | null;
+ readonly skipped_ledger_lines: number;
+ };
+}
+
+function counterOf(sum: CounterSum): EnvelopeCounter {
+ return { tokens: sum.known, unknown_records: sum.unknownRecords };
+}
+
+function tallyOf(tally: Tally): EnvelopeTally {
+ return {
+ records: tally.records,
+ input: counterOf(tally.counters.input),
+ output: counterOf(tally.counters.output),
+ cache_read: counterOf(tally.counters.cache_read),
+ cache_write: counterOf(tally.counters.cache_write),
+ total: counterOf(tally.total),
+ };
+}
+
+function rowOf(row: ReportRow): EnvelopeRow {
+ const { value } = row;
+ return {
+ kind: value.kind,
+ key: value.kind === "value" ? value.value : null,
+ reason: value.kind === "unattributed" ? value.reason : null,
+ ...tallyOf(row),
+ };
+}
+
+export function reportEnvelopeOf(
+ result: Extract
+): ReportEnvelope {
+ const { period, report, coverage } = result;
+ return {
+ version: 1,
+ period,
+ axis: report.axis,
+ rows: report.rows.map(rowOf),
+ totals: tallyOf(report.totals),
+ unknown_records: report.totals.total.unknownRecords,
+ coverage: {
+ files_read: coverage.filesRead,
+ records: coverage.records,
+ unrecognised_shapes: coverage.unrecognised,
+ not_stored: coverage.notStored,
+ consent_log_damaged: coverage.consentLogDamaged,
+ oldest_transcript_at: coverage.oldestTranscriptAt,
+ skipped_ledger_lines: coverage.skippedLedgerLines,
+ },
+ };
+}
+
+/** What `--json` prints under `AIDD_TELEMETRY=0`, when nothing was read. */
+export const REFUSED_ENVELOPE = { version: 1, refused: "AIDD_TELEMETRY=0" } as const;
diff --git a/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts b/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts
new file mode 100644
index 000000000..1fbf63e99
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts
@@ -0,0 +1,82 @@
+import type { PersonIdentityStore } from "../../domain/ports/identity/person-identity-store.js";
+import type { UsageLedger } from "../../domain/ports/usage-ledger.js";
+import { inPeriod, type Period } from "../../domain/report/period.js";
+import {
+ buildReport,
+ type ReportAxis,
+ type UsageReport,
+} from "../../domain/report/usage-report.js";
+import type { NotStoredReason } from "../../domain/repository-resolution.js";
+import type { IngestUsageUseCase } from "../ingest-usage-use-case.js";
+import type { DeclaredBindings } from "./declared-bindings.js";
+
+export interface ReportRequest {
+ readonly axis: ReportAxis;
+ readonly period: Period;
+}
+
+/** What the figures rest on, so a reader can tell a small number from a missing one. */
+export interface ReportCoverage {
+ /** Transcripts read by this run: new, or grown since the last. */
+ readonly filesRead: number;
+ /** Billed calls in the report's period. */
+ readonly records: number;
+ /** Shapes this run met and did not recognise, so did not count. */
+ readonly unrecognised: number;
+ /** Billed calls this run read and did not store, by the reason. */
+ readonly notStored: Readonly>;
+ /** A line of `ledger/consents.jsonl` was not an event: nothing is stored until it is recovered. */
+ readonly consentLogDamaged: boolean;
+ /** When the oldest transcript still on disk was last written: nothing older can be read. */
+ readonly oldestTranscriptAt: string | null;
+ /** Lines of the ledger that were not records. */
+ readonly skippedLedgerLines: number;
+}
+
+export type ReportResult =
+ | { readonly status: "refused" }
+ | {
+ readonly status: "reported";
+ readonly period: Period;
+ readonly report: UsageReport;
+ readonly coverage: ReportCoverage;
+ };
+
+/** Reads what is new, then answers what the stored calls consumed in a period, split along one
+ * axis. The ledger holds facts only: what a call belongs to is worked out here, every time, from
+ * what people declared, so a declaration made later moves earlier work. */
+export class ReportUsageUseCase {
+ constructor(
+ private readonly ingest: IngestUsageUseCase,
+ private readonly ledger: UsageLedger,
+ private readonly declared: DeclaredBindings,
+ private readonly identity: PersonIdentityStore
+ ) {}
+
+ async execute(request: ReportRequest): Promise {
+ const ingested = await this.ingest.execute();
+ if (ingested.refused) return { status: "refused" };
+ const held = await this.ledger.exclusively(() => this.ledger.load());
+ const records = held.records.filter((record) => inPeriod(record.at, request.period));
+ const report = buildReport(
+ records,
+ await this.declared.read(),
+ await this.identity.read(),
+ request.axis
+ );
+ return {
+ status: "reported",
+ period: request.period,
+ report,
+ coverage: {
+ filesRead: ingested.filesRead,
+ records: records.length,
+ unrecognised: ingested.unrecognised,
+ notStored: ingested.notStored,
+ consentLogDamaged: ingested.consentLogDamaged,
+ oldestTranscriptAt: ingested.oldestTranscriptAt,
+ skippedLedgerLines: held.skippedLines,
+ },
+ };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/snapshot-bindings-use-case.ts b/cli/src/contexts/telemetry/application/snapshot-bindings-use-case.ts
new file mode 100644
index 000000000..8546fda7e
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/snapshot-bindings-use-case.ts
@@ -0,0 +1,52 @@
+import {
+ type BranchConfigBinding,
+ needsReflog,
+ nextSnapshot,
+ snapshotKey,
+} from "../domain/branch-binding.js";
+import type { BindingSnapshotStore } from "../domain/ports/bindings/binding-snapshot-store.js";
+import type { BindingsLock, LockWait } from "../domain/ports/bindings/bindings-lock.js";
+import type { BranchBindingSource } from "../domain/ports/branch-binding-source.js";
+
+/** Records what a repository's branches are declared as, right now. Attribution reads only
+ * these snapshots, so a branch that is deleted or renamed later keeps the work it carried. */
+export class SnapshotBindingsUseCase {
+ constructor(
+ private readonly source: BranchBindingSource,
+ private readonly store: BindingSnapshotStore,
+ private readonly lock: BindingsLock,
+ private readonly now: () => Date
+ ) {}
+
+ /** How many snapshots were added: none when every branch still reads as it did. Reading
+ * the branch config and the latest snapshots, and appending to them, is one step under the
+ * bindings lock, because a declaration and an ingest both take snapshots and a config read
+ * before the lock could be older than a snapshot taken while waiting for it. A repository
+ * declaring nothing is told so without taking the lock. */
+ async execute(repositoryId: string, root: string, wait?: LockWait): Promise {
+ if ((await this.source.bindings(root)).length === 0) return 0;
+ return this.lock.exclusively(
+ async () => this.append(repositoryId, root, await this.source.bindings(root)),
+ wait
+ );
+ }
+
+ private async append(
+ repositoryId: string,
+ root: string,
+ observed: readonly BranchConfigBinding[]
+ ): Promise {
+ const latest = await this.store.latest();
+ const taken = [];
+ for (const binding of observed) {
+ const previous = latest.get(snapshotKey(repositoryId, binding.branch));
+ const created = needsReflog(previous, binding)
+ ? await this.source.createdAt(root, binding.branch)
+ : null;
+ const next = nextSnapshot(repositoryId, previous, binding, created, this.now().toISOString());
+ if (next !== null) taken.push(next);
+ }
+ if (taken.length > 0) await this.store.append(taken);
+ return taken.length;
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/switch/clone-consent.ts b/cli/src/contexts/telemetry/application/switch/clone-consent.ts
new file mode 100644
index 000000000..23672cda2
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/switch/clone-consent.ts
@@ -0,0 +1,26 @@
+import type { ConsentSource } from "../../domain/ports/consent-source.js";
+import type { LocatedDirectory, RepositoryLocator } from "../../domain/ports/repository-locator.js";
+
+export type CloneConsent =
+ | { readonly status: "refused"; readonly reason: "outside-repository" | "unreadable-git-config" }
+ | {
+ readonly status: "read";
+ readonly located: Extract;
+ /** The value of `aidd.telemetry`, `null` when it is not set. */
+ readonly value: string | null;
+ };
+
+/** The clone a directory is in, and what its git config says about measuring it. */
+export async function readCloneConsent(
+ locator: RepositoryLocator,
+ consents: ConsentSource,
+ cwd: string
+): Promise {
+ const located = await locator.locate(cwd);
+ if (located.status === "unreadable")
+ return { status: "refused", reason: "unreadable-git-config" };
+ if (located.status !== "repository") return { status: "refused", reason: "outside-repository" };
+ const reading = await consents.read(located.root);
+ if (reading.kind === "unreadable") return { status: "refused", reason: "unreadable-git-config" };
+ return { status: "read", located, value: reading.value };
+}
diff --git a/cli/src/contexts/telemetry/application/switch/remember-own-root.ts b/cli/src/contexts/telemetry/application/switch/remember-own-root.ts
new file mode 100644
index 000000000..cef802429
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/switch/remember-own-root.ts
@@ -0,0 +1,39 @@
+import type { LocatedDirectory } from "../../domain/ports/repository-locator.js";
+import type { ResolutionStore } from "../../domain/ports/resolution-store.js";
+import { repositoryIdOf } from "../../domain/repository-identity.js";
+import {
+ cwdKey,
+ type RepositoryResolution,
+ resolutionKey,
+ sameResolution,
+} from "../../domain/repository-resolution.js";
+
+type Located = Extract;
+
+/** Remembers the root `on` ran in, seen alive in its clone, so a declaration made there is
+ * known to belong to a repository `forget` can find, though no session ever ran there. A
+ * repository with neither a remote nor a commit has nothing to be named by, and a clone the
+ * platform cannot identify cannot be remembered: neither is. */
+export async function rememberOwnRoot(
+ store: ResolutionStore,
+ located: Located,
+ caseInsensitive: boolean,
+ at: Date
+): Promise {
+ const id = repositoryIdOf(located);
+ if (id === null || located.clone === null) return;
+ const held = await store.load();
+ const dir = cwdKey(located.root, caseInsensitive);
+ const key = resolutionKey(dir, located.clone);
+ const earlier = held.get(key);
+ const own: RepositoryResolution = {
+ dir,
+ repository_id: id,
+ root: located.root,
+ clone: located.clone,
+ seen_at: earlier?.seen_at ?? at.toISOString(),
+ };
+ if (sameResolution(earlier, own)) return;
+ held.set(key, own);
+ await store.save(held);
+}
diff --git a/cli/src/contexts/telemetry/application/switch/telemetry-off-use-case.ts b/cli/src/contexts/telemetry/application/switch/telemetry-off-use-case.ts
new file mode 100644
index 000000000..212e4b64d
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/switch/telemetry-off-use-case.ts
@@ -0,0 +1,69 @@
+import { sameClone } from "../../domain/consent/clone-identity.js";
+import type { ConsentHistory } from "../../domain/ports/consent-history.js";
+import type { ConsentSource } from "../../domain/ports/consent-source.js";
+import type { RepositoryLocator } from "../../domain/ports/repository-locator.js";
+import type { ConsentWriter } from "../../domain/ports/switch/consent-writer.js";
+import type { UsageLedger } from "../../domain/ports/usage-ledger.js";
+import { CONSENT_WITHDRAWN, tokenOfKey } from "../../domain/telemetry-consent.js";
+import { ConsentLog } from "../consent-log.js";
+import { readCloneConsent } from "./clone-consent.js";
+
+export type OffResult =
+ | {
+ readonly status: "refused";
+ readonly reason: "outside-repository" | "unreadable-git-config" | "damaged-consent-log";
+ }
+ | { readonly status: "off"; readonly changed: boolean };
+
+/** Stops measuring this clone: its interval ends now, and the calls it made while on stay
+ * stored, whenever the next ingest runs. What was already measured stays until `forget`. */
+export class TelemetryOffUseCase {
+ constructor(
+ private readonly locator: RepositoryLocator,
+ private readonly consents: ConsentSource,
+ private readonly writer: ConsentWriter,
+ private readonly ledger: UsageLedger,
+ private readonly history: ConsentHistory,
+ private readonly now: () => Date
+ ) {}
+
+ async execute(cwd: string): Promise {
+ const clone = await readCloneConsent(this.locator, this.consents, cwd);
+ if (clone.status === "refused") return clone;
+ const { located } = clone;
+ const token = tokenOfKey(clone.value);
+ const granted = token !== null;
+ let closed = false;
+ let damaged = false;
+ // The lock first: an ingest running now must see the key and the interval change together.
+ await this.ledger.exclusively(async () => {
+ const log = await ConsentLog.load(this.history);
+ // A log that cannot be trusted is not added to, and the key is left as it is.
+ if (log.damaged) {
+ damaged = true;
+ return;
+ }
+ const here = located.clone;
+ if (here !== null) {
+ const at = this.now();
+ // The interval the key names at this path is this clone's too, whatever identity is read
+ // now: where Linux refuses `statx`, the identity moves with each entry added to the git
+ // dir, `on`'s own write included. A copy elsewhere carries the token, not the path.
+ const ours = log
+ .openIntervals()
+ .filter(
+ (interval) =>
+ sameClone(interval.clone, here) ||
+ (interval.token === token && interval.clone.path === here.path)
+ );
+ for (const interval of ours) {
+ await log.close(interval.token, at);
+ closed = true;
+ }
+ }
+ if (granted) await this.writer.set(located.root, CONSENT_WITHDRAWN);
+ });
+ if (damaged) return { status: "refused", reason: "damaged-consent-log" };
+ return { status: "off", changed: granted || closed };
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts b/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts
new file mode 100644
index 000000000..4729d5def
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts
@@ -0,0 +1,151 @@
+import type { CloneIdentity } from "../../domain/consent/clone-identity.js";
+import type { ConsentHistory } from "../../domain/ports/consent-history.js";
+import type { ConsentSource } from "../../domain/ports/consent-source.js";
+import type { LocatedDirectory, RepositoryLocator } from "../../domain/ports/repository-locator.js";
+import type { ResolutionStore } from "../../domain/ports/resolution-store.js";
+import type { ClaudeSettingsSource } from "../../domain/ports/switch/claude-settings-source.js";
+import type { ConsentWriter } from "../../domain/ports/switch/consent-writer.js";
+import type {
+ HookCleanup,
+ LegacyHookCleaner,
+} from "../../domain/ports/switch/legacy-hook-cleaner.js";
+import type { ProjectConfigFile } from "../../domain/ports/switch/project-config-file.js";
+import type {
+ JournalCleanup,
+ RunJournalCleaner,
+} from "../../domain/ports/switch/run-journal-cleaner.js";
+import type { UsageLedger } from "../../domain/ports/usage-ledger.js";
+import { effectiveRetentionDays, retentionShort } from "../../domain/switch/claude-retention.js";
+import { withoutPreviousTelemetry } from "../../domain/switch/legacy-config.js";
+import { consentValue, tokenOfKey } from "../../domain/telemetry-consent.js";
+import { ConsentLog } from "../consent-log.js";
+import type { ResolutionEnvironment } from "../directory-resolver.js";
+import { readCloneConsent } from "./clone-consent.js";
+import { rememberOwnRoot } from "./remember-own-root.js";
+
+type Located = Extract;
+
+export type LegacyConfigOutcome = "none" | "block-removed" | "file-deleted" | "unparseable";
+
+export type OnResult =
+ | {
+ readonly status: "refused";
+ /** `unidentified-clone`: the file system gives the clone's git dir no identity, so
+ * nothing measured could be told to be its own. `damaged-consent-log`: what the log says
+ * cannot be trusted, so nothing is added to it; `forget --yes` is the way out. */
+ readonly reason:
+ | "outside-repository"
+ | "unreadable-git-config"
+ | "unidentified-clone"
+ | "damaged-consent-log";
+ }
+ | {
+ readonly status: "on";
+ /** `false` when the clone already granted this version. */
+ readonly consentWritten: boolean;
+ /** What was done to the previous version's block in `.aidd/config.json`, a file a team
+ * may track. */
+ readonly legacyConfig: LegacyConfigOutcome;
+ readonly hook: HookCleanup;
+ readonly journal: JournalCleanup;
+ /** How long Claude Code keeps a transcript, and whether that is short of what history
+ * needs. */
+ readonly retention: { readonly days: number; readonly short: boolean };
+ };
+
+/** Opts a clone in, in its own git config, and leaves it as if the previous measurement had
+ * never run. Measurement starts here: nothing made before is read back. */
+export class TelemetryOnUseCase {
+ constructor(
+ private readonly locator: RepositoryLocator,
+ private readonly consents: ConsentSource,
+ private readonly writer: ConsentWriter,
+ private readonly config: ProjectConfigFile,
+ private readonly hooks: LegacyHookCleaner,
+ private readonly journal: RunJournalCleaner,
+ private readonly ledger: UsageLedger,
+ private readonly resolutions: ResolutionStore,
+ private readonly history: ConsentHistory,
+ private readonly claudeSettings: ClaudeSettingsSource,
+ private readonly environment: ResolutionEnvironment,
+ /** A fresh random token for each interval. */
+ private readonly newToken: () => string
+ ) {}
+
+ async execute(cwd: string): Promise {
+ const clone = await readCloneConsent(this.locator, this.consents, cwd);
+ if (clone.status === "refused") return clone;
+ const { located } = clone;
+ const identity = located.clone;
+ if (identity === null) return { status: "refused", reason: "unidentified-clone" };
+ const consentWritten = await this.ledger.exclusively(() => this.grant(located.root, identity));
+ if (consentWritten === "damaged") return { status: "refused", reason: "damaged-consent-log" };
+ const legacyConfig = await this.clearLegacyConfig(located.root);
+
+ // Pairing and removal live in one adapter call: the line and its script go together.
+ const hook = await this.hooks.clean(located.root);
+ const journal = await this.journal.clean(located.root);
+ await this.rememberRoot(located);
+
+ const days = effectiveRetentionDays(await this.claudeSettings.texts(located.root));
+ return {
+ status: "on",
+ consentWritten,
+ legacyConfig,
+ hook,
+ journal,
+ retention: { days, short: retentionShort(days) },
+ };
+ }
+
+ /** The block the previous version wrote in `.aidd/config.json` means nothing to this one:
+ * removed, the rest of the file as it was, and the file itself only when nothing else is
+ * in it. */
+ private async clearLegacyConfig(root: string): Promise {
+ const cleaning = withoutPreviousTelemetry(await this.config.read(root));
+ if (cleaning.status === "block-removed") {
+ await this.config.write(root, cleaning.text);
+ return "block-removed";
+ }
+ if (cleaning.status === "file-emptied") {
+ await this.config.remove(root);
+ return "file-deleted";
+ }
+ return cleaning.status;
+ }
+
+ /** Under the ledger's lock, so an ingest sees the key and the interval together. A key that
+ * names an interval open for this very clone is already consent. Any other is replaced by a
+ * fresh token: the key first, so that a crash between the two leaves a key with no interval,
+ * which measures nothing, and not an interval its own key does not name, which a hook would
+ * close. */
+ private async grant(root: string, identity: CloneIdentity): Promise {
+ const log = await ConsentLog.load(this.history);
+ // A log that cannot be trusted is not added to: an interval opened over it could span a
+ // window the lost lines closed.
+ if (log.damaged) return "damaged";
+ // Read again under the lock: another `on` may have written it since the first look.
+ const reading = await this.consents.read(root);
+ const held = reading.kind === "value" ? tokenOfKey(reading.value) : null;
+ const open = log.openFor(identity);
+ if (held !== null && open.some((interval) => interval.token === held)) return false;
+ const at = this.environment.now();
+ for (const stale of open) await log.close(stale.token, at);
+ const token = this.newToken();
+ await this.writer.set(root, consentValue(token));
+ await log.open(identity, token, at);
+ return true;
+ }
+
+ /** Under the ledger's lock, which every writer of `roots.json` holds. */
+ private async rememberRoot(located: Located): Promise {
+ await this.ledger.exclusively(() =>
+ rememberOwnRoot(
+ this.resolutions,
+ located,
+ this.environment.caseInsensitiveFileSystem,
+ this.environment.now()
+ )
+ );
+ }
+}
diff --git a/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts
deleted file mode 100644
index 8e119a314..000000000
--- a/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import type { FileReader } from "../../../kernel/ports/file-reader.js";
-import type { FileWriter } from "../../../kernel/ports/file-writer.js";
-import type { Logger } from "../../../kernel/ports/logger.js";
-import {
- SESSION_TRAILER_DELEGATE_FILE,
- SESSION_TRAILER_TOKEN,
- sessionTrailerManagerSnippet,
-} from "../domain/formats/commit-session-trailer.js";
-import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js";
-import type { VersionControl } from "../domain/ports/version-control.js";
-import {
- buildTelemetrySwitchFile,
- parseTelemetrySwitchFile,
- telemetryConfigPath,
-} from "../domain/telemetry-switch.js";
-
-export interface TelemetryOffOptions {
- readonly projectRoot: string;
-}
-
-export interface TelemetryOffResult {
- readonly switchPath: string;
- readonly switchChanged: boolean;
-}
-
-/** Sets the switch off, preserving any `endpoint` the file already carries. Never edits a tool's
- * own settings file: nothing here wrote one, so editing it could erase somebody's real setup. */
-export class TelemetryOffUseCase {
- constructor(
- private readonly fs: FileReader & FileWriter,
- private readonly logger: Logger,
- private readonly telemetryEvidenceReader: TelemetryEvidenceReader,
- private readonly git: VersionControl
- ) {}
-
- async execute(options: TelemetryOffOptions): Promise {
- const switchPath = telemetryConfigPath(options.projectRoot);
- this.logger.info(`AIDD telemetry switch -> ${switchPath}`);
- const switchChanged = await this.turnSwitchOff(switchPath);
- await this.stopTrailingCommits(options.projectRoot);
- await this.warnLeftoverExportConfig(options.projectRoot);
- return { switchPath, switchChanged };
- }
-
- /** `on` wrote the hook line and the delegate, so `off` takes both back โ whatever the switch's
- * previous state was: already off with the hook installed is what a second `off` must fix. */
- private async stopTrailingCommits(projectRoot: string): Promise {
- const result = await this.git.removeCommitMessageDelegate(
- projectRoot,
- SESSION_TRAILER_DELEGATE_FILE
- );
- if (result.removed) {
- this.logger.info(
- `New commits will carry no ${SESSION_TRAILER_TOKEN} trailer. Commits already made ` +
- "keep theirs โ nothing here rewrites history."
- );
- }
- // A manager's own config is committed, shared config this CLI never writes, so the job
- // calling the removed delegate stays โ its own `[ -f ]` guard now finds nothing to run.
- if (result.hookManager !== undefined && result.managerCallsDelegate === true) {
- const { targetFile } = sessionTrailerManagerSnippet(
- result.hookManager,
- SESSION_TRAILER_DELEGATE_FILE
- );
- this.logger.info(
- `${targetFile} still calls the delegate this just removed โ that file is not this ` +
- "CLI's to edit, so the job is left in place. Its own `[ -f ]` guard now finds " +
- "nothing there, so it runs nothing; delete it from " +
- `${targetFile} by hand if you want it gone too.`
- );
- }
- }
-
- /** Names what `off` cannot touch: a tool's own settings file still carrying an export key.
- * Silence is the failure this closes โ nothing else tells a person their machine exports. */
- private async warnLeftoverExportConfig(projectRoot: string): Promise {
- const leftovers = await this.telemetryEvidenceReader.findLeftoverExportConfig(projectRoot);
- for (const leftover of leftovers) {
- this.logger.warn(
- `${leftover.path} still sets ${leftover.keys.join(", ")} โ this switch cannot ` +
- "touch a tool's own settings file. Delete these keys from its `env` block by " +
- "hand to stop that export."
- );
- }
- }
-
- private async turnSwitchOff(switchPath: string): Promise {
- if (!(await this.fs.fileExists(switchPath))) {
- this.logger.info("AIDD telemetry: already off, unchanged.");
- return false;
- }
- const raw = await this.fs.readFile(switchPath);
- const current = parseTelemetrySwitchFile(raw);
- if (current?.enabled !== true) {
- this.logger.info("AIDD telemetry: already off, unchanged.");
- return false;
- }
- const next = buildTelemetrySwitchFile(raw, { enabled: false, endpoint: current.endpoint });
- await this.fs.writeFile(switchPath, next);
- this.logger.info("AIDD telemetry: off.");
- return true;
- }
-}
diff --git a/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts
deleted file mode 100644
index 36c53e10a..000000000
--- a/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { TelemetryProjectScopeRequiresYesError } from "../../../kernel/errors.js";
-import { RUNS_ENTRY } from "../../../kernel/paths.js";
-import type { FileReader } from "../../../kernel/ports/file-reader.js";
-import type { FileWriter } from "../../../kernel/ports/file-writer.js";
-import type { Logger } from "../../../kernel/ports/logger.js";
-import {
- SESSION_TRAILER_DELEGATE_FILE,
- SESSION_TRAILER_TOKEN,
- sessionTrailerDelegateScript,
- sessionTrailerManagerInstruction,
- sessionTrailerManagerSnippet,
-} from "../domain/formats/commit-session-trailer.js";
-import type { IgnoreEntries } from "../domain/ports/ignore-entries.js";
-import type { TelemetrySink } from "../domain/ports/telemetry-sink.js";
-import type { VersionControl } from "../domain/ports/version-control.js";
-import type { HookManager } from "../domain/telemetry-setup.js";
-import {
- buildTelemetrySwitchFile,
- parseTelemetrySwitchFile,
- type TelemetrySwitch,
- telemetryConfigPath,
-} from "../domain/telemetry-switch.js";
-
-export interface TelemetryOnOptions {
- readonly projectRoot: string;
- /** `.aidd/config.json` is git-tracked, so a fresh clone inherits the project's decision โ
- * which is why anyone is asked at all. */
- readonly confirmed: boolean;
-}
-
-export interface TelemetryOnResult {
- readonly switchPath: string;
- readonly switchChanged: boolean;
-}
-
-/** Owns the AIDD telemetry switch alone: flips `.aidd/config.json`'s `telemetry.enabled` and
- * git-ignores the run journal. Never touches a tool's own settings file โ arming a tool to
- * export and recording locally are two different promises. Any `endpoint` there is preserved. */
-export class TelemetryOnUseCase {
- constructor(
- private readonly fs: FileReader & FileWriter,
- private readonly logger: Logger,
- private readonly gitignore: IgnoreEntries,
- private readonly git: VersionControl,
- private readonly sink: TelemetrySink
- ) {}
-
- async execute(options: TelemetryOnOptions): Promise {
- const switchPath = telemetryConfigPath(options.projectRoot);
- this.guardConfirmed(options);
- // Before the switch, not after: `appendRecord` creates the directory itself, so without this
- // the first failure lands at the first record, on whoever reads rather than whoever turned on.
- await this.sink.ensureWritable();
- this.logger.info(`AIDD telemetry switch -> ${switchPath}`);
- // The switch is written last, once everything it promises is in place: a failure partway
- // through must never leave `enabled: true` describing a setup that stopped short of it.
- await this.protectRunsDir(options.projectRoot);
- await this.makeCommitsJoinable(options.projectRoot);
- const switchChanged = await this.writeSwitch(switchPath);
- return { switchPath, switchChanged };
- }
-
- // `.aidd/config.json` is git-tracked, the consequence `endpoint --scope project` already
- // refuses without `--yes`. Fires unconditionally, whatever the switch's current state.
- private guardConfirmed(options: TelemetryOnOptions): void {
- if (options.confirmed) return;
- throw new TelemetryProjectScopeRequiresYesError(
- "aidd telemetry on",
- telemetryConfigPath(options.projectRoot)
- );
- }
-
- // Re-checked on every successful `on`, switch newly written or not: a project turned on before
- // this existed still gets the journal ignored, without turning telemetry off and on again.
- private async protectRunsDir(projectRoot: string): Promise {
- const added = await this.gitignore.execute(projectRoot, [RUNS_ENTRY]);
- if (added) {
- this.logger.info(
- `Added ${RUNS_ENTRY} to .gitignore โ the journal names no person, only the ` +
- "repository, the task folders written into, the skills run, and their timings. " +
- "Delete that line to commit it instead."
- );
- }
- const tracked = await this.git.listTrackedFiles(projectRoot, RUNS_ENTRY);
- if (tracked.length === 0) return;
- this.logger.warn(
- "Already tracked by git โ the repository, the task folders written into, the skills " +
- `run, and their timings:\n${tracked.map((file) => ` ${file}`).join("\n")}\n` +
- "Nothing removed or rewritten โ your call."
- );
- }
-
- /** Re-run on every successful `on`, switch newly written or not, for the same reason
- * `protectRunsDir` is. Announced rather than done quietly: it writes into commit messages a
- * team will read, so the sentence saying so and the command undoing it must be findable. */
- private async makeCommitsJoinable(projectRoot: string): Promise {
- const install = await this.git.installCommitMessageDelegate(
- projectRoot,
- SESSION_TRAILER_DELEGATE_FILE,
- sessionTrailerDelegateScript()
- );
- if (install.hookManager !== undefined) {
- this.reportManagedHook(install.hookManager, install.managerCallsDelegate === true);
- return;
- }
- if (!install.lineAdded) return;
- this.logger.info(
- `Commits made by an AI session will carry an ${SESSION_TRAILER_TOKEN} trailer, so what ` +
- "a session cost can be read per commit. A commit no session made carries nothing. " +
- "`aidd telemetry off` removes it."
- );
- }
-
- /** `prepare-commit-msg` under a manager is committed, shared config this CLI may not append
- * to โ an append would reach every clone through a commit nobody reviewed โ and lefthook
- * regenerates it from that config anyway. A chain already wired prints nothing: the ordinary
- * trailer promise above already covers it. */
- private reportManagedHook(manager: HookManager, wired: boolean): void {
- if (wired) return;
- const { targetFile, snippet } = sessionTrailerManagerSnippet(
- manager,
- SESSION_TRAILER_DELEGATE_FILE
- );
- this.logger.info(
- `${manager} owns prepare-commit-msg here, so nothing was appended to it. Commits will ` +
- `not carry an ${SESSION_TRAILER_TOKEN} trailer until you ` +
- `${sessionTrailerManagerInstruction(manager, targetFile)}:\n\n${snippet}\n`
- );
- }
-
- private async readIfExists(path: string): Promise {
- return (await this.fs.fileExists(path)) ? await this.fs.readFile(path) : null;
- }
-
- private async writeSwitch(switchPath: string): Promise {
- const existingRaw = await this.readIfExists(switchPath);
- const existing: TelemetrySwitch | null =
- existingRaw !== null ? parseTelemetrySwitchFile(existingRaw) : null;
- if (existing?.enabled === true) {
- this.logger.info("AIDD telemetry: already on, unchanged.");
- return false;
- }
- const next = buildTelemetrySwitchFile(existingRaw, {
- enabled: true,
- endpoint: existing?.endpoint,
- });
- await this.fs.writeFile(switchPath, next);
- this.logger.info("AIDD telemetry: on.");
- return true;
- }
-}
diff --git a/cli/src/contexts/telemetry/application/telemetry-status-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-status-use-case.ts
new file mode 100644
index 000000000..e3adde8d0
--- /dev/null
+++ b/cli/src/contexts/telemetry/application/telemetry-status-use-case.ts
@@ -0,0 +1,55 @@
+import { type Binding, resolveBinding } from "../domain/declaration/binding-resolution.js";
+import type { BranchRole } from "../domain/declaration/branch-role.js";
+import type { SessionBindingStore } from "../domain/ports/bindings/session-binding-store.js";
+import type { BranchDeclarations } from "./branch-declarations.js";
+import type { ConsentedRepositories, RefusalReason } from "./consented-repositories.js";
+
+export type StatusResult =
+ | { readonly status: "refused"; readonly reason: RefusalReason }
+ | {
+ readonly status: "shown";
+ /** When this clone's measurement began. */
+ readonly since: string;
+ readonly sessionId: string | null;
+ readonly branch: string | null;
+ readonly role: BranchRole;
+ readonly binding: Binding;
+ };
+
+export interface StatusOptions {
+ readonly refusedByEnvironment: boolean;
+ readonly sessionId: string | null;
+ readonly now: () => Date;
+}
+
+/** What the work being done now is bound to, and where that comes from. Reads only. */
+export class TelemetryStatusUseCase {
+ constructor(
+ private readonly repositories: ConsentedRepositories,
+ private readonly sessions: SessionBindingStore,
+ private readonly branches: BranchDeclarations,
+ private readonly options: StatusOptions
+ ) {}
+
+ async execute(cwd: string): Promise {
+ if (this.options.refusedByEnvironment) return { status: "refused", reason: "environment" };
+ const repository = await this.repositories.open(cwd);
+ if (repository.status === "refused") return repository;
+ const current = await this.branches.current(repository.root);
+ const binding = resolveBinding({
+ sessionId: this.options.sessionId,
+ at: this.options.now(),
+ declarations: await this.sessions.declarations(),
+ carries: await this.sessions.carries(),
+ branch: current.declared,
+ });
+ return {
+ status: "shown",
+ since: repository.since,
+ sessionId: this.options.sessionId,
+ branch: current.name,
+ role: current.role,
+ binding,
+ };
+ }
+}
diff --git a/cli/src/contexts/telemetry/domain/branch-binding.ts b/cli/src/contexts/telemetry/domain/branch-binding.ts
new file mode 100644
index 000000000..eadd93fc7
--- /dev/null
+++ b/cli/src/contexts/telemetry/domain/branch-binding.ts
@@ -0,0 +1,159 @@
+import { tryParseJson } from "../../../kernel/reading/json-file.js";
+import { asPlainObject } from "../../../kernel/reading/plain-object.js";
+import { compareText } from "./text-order.js";
+
+/** What a branch's git config says about its declaration. */
+export interface BranchConfigBinding {
+ readonly branch: string;
+ readonly task: string | null;
+ readonly ticket: string | null;
+ /** As the declaration wrote it. */
+ readonly declared_at: string | null;
+ /** Declared, and declared to have no task. */
+ readonly none: boolean;
+}
+
+/** A branch's declaration at one moment, kept so a deleted or renamed branch still attributes
+ * the work it carried. */
+export interface BranchSnapshot extends BranchConfigBinding {
+ readonly repository_id: string;
+ /** The oldest reflog entry when first read: the work before it was not on this branch. */
+ readonly branch_created_at: string | null;
+ readonly snapshot_at: string;
+}
+
+export function snapshotKey(repositoryId: string, branch: string): string {
+ return `${repositoryId}\u0000${branch}`;
+}
+
+const PREFIX = "branch.";
+/** git lists the variable part of a key in lower case; the branch name keeps its case. */
+const FIELDS = {
+ task: ".aiddtask",
+ ticket: ".aiddticket",
+ declared_at: ".aidddeclaredat",
+} as const;
+
+interface DeclaredFields {
+ task: string | null;
+ ticket: string | null;
+ declared_at: string | null;
+}
+
+function textOrNull(value: string): string | null {
+ return value === "" ? null : value;
+}
+
+/** Reads `git config --local -z --get-regexp` output: entries end in NUL, a key and its value
+ * are split by a newline, and a key given with no value has no newline at all. A branch name
+ * can hold dots, so the known suffix is cut off the end, never split on. */
+export function parseBranchConfig(output: string): BranchConfigBinding[] {
+ const branches = new Map();
+ for (const entry of output.split("\0")) {
+ if (!entry.startsWith(PREFIX)) continue;
+ const newline = entry.indexOf("\n");
+ const key = newline === -1 ? entry : entry.slice(0, newline);
+ const value = newline === -1 ? "" : entry.slice(newline + 1);
+ const lower = key.toLowerCase();
+ for (const [field, suffix] of Object.entries(FIELDS) as [keyof DeclaredFields, string][]) {
+ if (!lower.endsWith(suffix)) continue;
+ const branch = key.slice(PREFIX.length, key.length - suffix.length);
+ const held = branches.get(branch) ?? { task: null, ticket: null, declared_at: null };
+ held[field] = textOrNull(value);
+ branches.set(branch, held);
+ }
+ }
+ return [...branches.entries()]
+ .sort(([a], [b]) => compareText(a, b))
+ .map(([branch, fields]) => ({
+ branch,
+ ...fields,
+ none: fields.declared_at !== null && fields.task === null,
+ }));
+}
+
+/** `git reflog show --date=iso-strict --format=%gd` lists newest first, each as
+ * `refs/heads/@{