# Ticket usage — time and tokens per ticket

> What did this ticket actually cost, start to finish, and which agents and models spent it?

AI Maestro doesn't run models itself. It conducts Claude Code and Codex against a board, so the
answer has to come from what those tools already record. This feature assembles it from two
sources and never confuses them:

| | Source | What it gives you | Honesty |
| --- | --- | --- | --- |
| **Measured** | `board/telemetry.jsonl`, written by `maestro run` | Exact stage duration, exact cycle time, and provider-reported token counts | It is a measurement |
| **Reconstructed** | Your local Claude Code and Codex session transcripts | Historical time and tokens for work already done | Inferred, with a stated confidence, and labelled *estimated* everywhere it appears |
| **Recorded** | `board/usage.jsonl` (committed) plus the untracked spool `board/.usage-pending.jsonl`, appended through `appendUsage()` (public `./usage` API) | Provider-reported per-turn token counts captured from agent sessions — count-only, no prompt or tool text | Exact counts per turn, labelled *recorded*; a session already measured by telemetry is not counted again, and a recorded session is skipped by the transcript scan |
| **Application** | `board/application-usage.jsonl`, appended through the public API | Exact provider counters for product/API calls such as DeepSeek | Exact token counts; never presented as agent runs or working time |

Ticket totals are **derived** from all of them.

**The recorded ledger is committed.** `board/usage.jsonl` is appended from many branches and
worktrees, so it is merged with `merge=union` — `sync.mjs` adds the rule
`<board>/usage.jsonl merge=union` to your repo's `.gitattributes` if it is missing (it never
rewrites the file). A union merge can duplicate a line; every reader dedups by the record's
`key` (`runtime:sessionId:turnId`), so a duplicate is never counted twice. Recording is on by
default; set `"usage": { "record": false }` in `config.json` to turn it off. Import progress is
kept in the untracked `board/.usage-cursor.json` (older kits used the tracked
`board/usage-cursor.json`; it is still read, never written again). Nothing is written onto the
ticket record itself.

### Pending records and `maestro usage commit`

Recording (the hooks, `maestro usage sync`, `appendUsage()`) never writes the tracked
`board/usage.jsonl`. New records go to the **untracked** spool `board/.usage-pending.jsonl`, so
a session running in your main checkout never leaves it dirty — `git pull --ff-only` and
`npm run release:prepare` keep working. Every reader (the report, `--json`/`--csv`/`--html`,
the portfolio view, the public `./usage` API and the web UI) reads `usage.jsonl` **plus** the
spool, deduplicated by key, so numbers show up immediately.

To put the records into git, fold them in on a branch and commit as usual:

```bash
git switch -c chore/usage-records
maestro usage commit          # folds board/.usage-pending.jsonl into board/usage.jsonl, empties the spool
git commit -am "chore: usage records" && git push   # then open a PR
```

`usage commit` merges by key (a record already in the ledger is not duplicated), keeps the
ledger sorted, takes the board lock and replaces the file atomically. It is idempotent: with
nothing pending it changes nothing. `--json` prints `{ committed, alreadyCommitted }`.
`maestro sync` adds `board/.usage-pending.jsonl` and `board/.usage-cursor.json` to `.gitignore`
(once, when usage recording is on).

**Upgrading from an older kit:** if your `board/usage.jsonl` already has uncommitted additions
(older hooks appended to it directly), the new hooks leave them alone. Commit them as they are,
or run `maestro usage commit` and commit the result — both keep every record.

### Importing transcripts: `maestro usage sync`

```bash
maestro usage sync                          # Claude Code + Codex, only sessions that changed
maestro usage sync --runtime codex          # claude | codex | all (default all)
maestro usage sync --session <id> --transcript <path>   # one transcript, e.g. from a hook
maestro usage sync --all                    # ignore the cursor and re-read every session (manual backfill)
maestro usage sync --exclude-root <dir>     # a nested project whose sessions are not ours
```

`sync` reads your local Claude Code (`~/.claude/projects`) and Codex (`~/.codex/sessions`)
transcripts for this project's roots, attributes each turn to a ticket (the `feat/T-xxx` branch
is high confidence; otherwise the same command/mention heuristics as the report), and appends
count-only records to the pending spool `board/.usage-pending.jsonl` (see
[`maestro usage commit`](#pending-records-and-maestro-usage-commit)). It uses the same token binning as the report: bins
are disjoint (Codex `input_tokens` includes cached input, which is subtracted), reasoning is
inside output, and `total = input + output + cacheRead + cacheWrite`. Codex usage comes from
per-turn `last_token_usage` (a repeated running total is not a new turn); `rate_limits` is ignored.

- **Complete per session.** When any file of a session changed, every turn of that session is
  re-offered and deduplicated by key, so a partially recorded session never hides turns
  from the report (which skips a recorded session in the transcript scan).
- **Idempotent.** A second run writes nothing — ledger and cursor bytes are unchanged.
- **Scoped.** Sessions from other repos and from `--exclude-root` nested projects are ignored.
- **Private.** No prompt, response or tool text and no paths are stored; cursor ids are
  `runtime:sessionId:file`.
- **Safe to run concurrently** (board lock) and bounded (transcripts over 256 MB are skipped
  and counted in the summary). With `"usage": { "record": false }` it writes nothing and says so.

### Automatic recording: Claude Code hooks

**Recording is ON by default.** `maestro sync` (and therefore `setup` and `update`, which run
it) adds three hooks to the project's `.claude/settings.json` — `Stop`, `SubagentStop` and
`SessionEnd`. Each reads the hook's stdin JSON (`session_id`, `transcript_path`, `cwd`) and runs
the equivalent of `maestro usage sync --runtime claude --session <id> --transcript <path>`
using `scripts/usage-hook.mjs` from the capsule-local kit (`./maestro/scripts/…` when vendored,
`./node_modules/@mychiefmind/ai-maestro/scripts/…` when installed as a package). It never uses
`npx` and never touches the network; if neither local path exists, sync warns and adds no hooks.

- **What is stored:** counts only — the same count-only records as `maestro usage sync` (token
  bins, model id, branch, ticket id, timestamps). No prompt, response, tool text or path.
- **Keeps the checkout clean:** records go to the untracked `board/.usage-pending.jsonl`;
  `maestro usage commit` folds them into the committed `board/usage.jsonl` (merged with
  `merge=union`, `.gitattributes` added by sync); readers dedup by key.
- **Never blocks the session:** the hook always exits 0 and prints nothing; the sync runs in a
  child process killed after 4 s, and the settings entry carries `"timeout": 5`. If another sync
  holds the board lock, the run is skipped (the next hook picks the turns up). Transcripts over
  64 MB are left to a manual `maestro usage sync`. Errors go to `board/.usage-hook.log` as one
  line of timestamp, event, error class and a sanitised error message (path-like tokens are
  dropped) — no prompts or paths. Sync adds that log to the project's `.gitignore`.
- **First-run backfill:** the hooks only see sessions that end after they were installed. So when
  a hook runs and the board has no import cursor yet (`board/.usage-cursor.json`, or the
  legacy `board/usage-cursor.json`), it starts a one-time full import
  of the project's existing transcripts (Claude main + subagents, and Codex; same ownership rules
  as `maestro usage sync`) in a **detached** background process — the hook does not wait for it.
  The cursor it leaves behind marks the backfill done. To backfill by hand (or re-read
  everything), run `maestro usage sync --all`. `MAESTRO_USAGE_BACKFILL=0` disables the automatic one.
- **Subagents:** `SubagentStop` also records the subagent's own transcript
  (`agent_transcript_path`), labelled with its agent type. If that file is not on disk yet the
  hook waits up to 1 s, then falls back to `<session>/subagents/agent-<agent_id>.jsonl` and
  finally to every `<session>/subagents/*.jsonl` (re-imports dedup by key).
- **Worktrees:** a session started in a linked `git worktree` of the project records onto the
  board of the **main checkout** (resolved via `git rev-parse --git-common-dir`), not the
  worktree's copy that is discarded with it. Sessions in linked worktrees of this repository
  count as the project's; a checkout elsewhere (e.g. under `/tmp`) that is not one of its
  worktrees stays excluded.
- **Repos without `maestro sync`:** the hooks live in `.claude/settings.json`, which only
  `maestro sync` writes. A repo that is not a Maestro project (the AI Maestro kit repo itself,
  for example) commits that file by hand — the kit repo's points at `scripts/usage-hook.mjs`
  and `board/`. Without hooks, run `maestro usage sync` before reading usage.
- **Formatting:** sync rewrites `.claude/settings.json` as 2-space JSON only when the hooks
  change; comments/formatting of that file are not preserved on such a write.
- **Your hooks are kept.** Existing entries are never changed or reordered; Maestro's entries
  are appended after them and marked with `# ai-maestro:usage-hook` so a re-sync replaces them
  in place (no duplicates) and an opt-out removes exactly them.
- **Codex** has no stable hooks. The `SessionEnd` hook also runs a cursor-based
  `maestro usage sync --runtime codex` inside the same time box — cheap, because unchanged
  rollouts are skipped by size. Codex-only users should run `maestro usage sync` themselves
  (e.g. before `maestro usage`).

**Opting out:** set `"usage": { "record": false }` in `config.json` and run `maestro sync`. The
Maestro hooks are removed (yours stay), and until you re-sync the hook command is a no-op.

---

## Why measurements are runs, not ticket fields

The obvious design is `started_at` / `ended_at` on the ticket. It is wrong. A ticket gets worked
more than once — a dev stage, a reviewer stage, a retry after a failed review, a model swapped
partway, a run resumed days later. All of that has to collapse onto one record, and the moment it
does you have lost the breakdown the dashboard exists to show.

So `board/board.schema.json` is untouched, and each run appends one line to
`board/telemetry.jsonl`:

```jsonc
{ "v": 1, "runId": "run_9f2c…", "ticketId": "T-033", "stage": "dev", "runtime": "claude",
  "model": "opus", "modelId": "claude-opus-5", "sessionId": "…",
  "startedAt": "…", "endedAt": "…", "durationMs": 812345,
  "usage": { "input": 1200, "output": 8400, "cacheRead": 91000, "cacheWrite": 43000, "thinking": 2600 },
  "usageSource": "provider", "outcome": "ok" }
```

Append-only, so two concurrent runs can't lose each other's writes and no lock is needed. The file
is git-ignored and excluded from `npm pack` — it describes one person's local runs.

**Readers must ignore fields they don't know, and writers must never repurpose one.** That is what
lets cost be added later, as a `cost` object on new records, without invalidating a single existing
line — and it is why this kit ships no price table. Rates change, differ per account, and a
subscription has no per-token price at all; a dollar figure computed from today's list price and
yesterday's tokens would be confidently wrong.

A stage that fails is still recorded. A twenty-minute run that then failed is exactly the run you
most want to see.

### Reading what the runtime reports

`scripts/run-stage.mjs` invokes the adapter and appends the record. It is deliberately separate
from `run-ticket.mjs`: the full dev → PR → reviewer pipeline needs a GitHub remote and a second
account's token, so a stage runner that only existed inside it could only be proven by doing
something irreversible.

Claude Code is asked for `-p --output-format json`, whose envelope carries `session_id`,
`duration_ms`, `usage` and a per-model `modelUsage`. **The two usage blocks are spelled
differently** — `usage` uses `input_tokens`, `modelUsage` uses `inputTokens` — and only the
top-level block reports reasoning tokens. So a single-model stage records the top-level block
(counts plus reasoning), and only a stage that spanned several models falls back to
`modelUsage`, where `thinking` is 0 because the runtime does not break reasoning down per model.
Reading one spelling for both wrote a record stamped `usageSource: "provider"` with every
counter at zero, which claims a stage was free; `test/fixtures/claude-json-envelope.json` is a
real envelope kept as a regression.

A caller who passes their own `--output-format` keeps it, and the run is then recorded with no
usage — their flag is an explicit instruction, and instrumentation does not override it.

Codex is capability-checked and, when supported, run once with `codex exec --json`. Maestro reads
the thread id, final agent message, terminal status and exact provider counters from that stream.
Cached input is subtracted from Codex's inclusive `input_tokens` into disjoint Maestro bins; a
malformed or unavailable block remains unavailable rather than being reported as zero. Runtime
(`codex`) and inference provider (`openai`, or another configured provider) are separate fields.

---

## Reconstructing history from transcripts

Opt-in, off by default, because it reads files outside the repo:

```jsonc
// config.json
"usage": { "scanTranscripts": true }
```

or `MAESTRO_USAGE_SCAN=1` for one command.

Claude Code writes one JSONL per session under `~/.claude/projects/<encoded-cwd>/`, plus one per
subagent with its `agentType` in a sibling meta file — which is where the per-agent breakdown comes
from. Reading is local and read-only.

Codex rollouts are read from both `$CODEX_HOME/sessions` and
`$CODEX_HOME/archived_sessions`. Per-response ids are deduplicated; older cumulative snapshots
are converted to positive deltas and reset safely after a regression. Exact run telemetry wins
over reconstruction for the same runtime-qualified session. DeepSeek ingestion is intentionally
deferred; the provider dimension is already ready to aggregate it without mislabelling runtime.

### Privacy

Everything downstream of the scanner inherits this: the `/api/usage` response, the JSON and CSV
exports, the HTML snapshot and the on-disk cache are all built from the report object, which
holds only counts, durations, ticket ids and board metadata. A test asserts end-to-end that a
secret present in a transcript appears in none of them.

A transcript is the most sensitive file on the disk. `scripts/usage-scan.mjs` is the only code that
opens one, and from each record it keeps **only** timestamps, model ids, token counts, the git
branch, session/agent ids, ticket-shaped identifiers, and board commands reduced to `(verb, id)`.
Message text, tool inputs, tool results and file contents are matched and dropped inside `distill()`.
The on-disk cache holds only that output, so it is aggregate-only by construction rather than by
promise. Mentions are deliberately **not** harvested from tool *results* — reading `archive.json`
into one would otherwise "mention" every archived ticket at once.

Cache keys and cwd ownership markers are SHA-256 fingerprints. Raw transcript and project paths,
prompts, responses, tool I/O and credentials are never persisted in the cache or returned by a
report.

### Ticket ids come from the board, not from this reader

Boards choose their own id prefixes — this kit uses `T-`, but other boards use `kit-096` or
`tl-226`. The scanner therefore matches anything ticket-SHAPED (a short prefix, a hyphen,
digits) and attribution accepts it **only if that board defines it**. Being generous in the
match is safe; being narrow is not. A hardcoded `T-` reported 0% of one board's 142 real
tickets as unattributable, which reads as "no work happened here" rather than "this reader only
knows one prefix". Noise like `UTF-8` is matched, carried, and then refused.

### The confidence ladder

Every turn lands with a confidence and the evidence that produced it. A turn that can't be justified
is left unassigned rather than guessed at.

| Confidence | Evidence |
| --- | --- |
| `exact` | A run telemetry record measured it |
| `high` | The git branch names the ticket (`codex/t-029-…`) |
| `high` | The session ran a board write for it (`set-status`, `archive`, `block`, `run`) |
| `medium` | The ticket was named — **and it exists on the board** |
| *unassigned* | Nothing above applied |

Two rules keep this honest, both learned from a naive first pass that credited `T-042` and `T-999` —
a doc example and a test fixture — with 150M tokens between them and looked entirely plausible:

1. **Ids absent from `data.json`/`archive.json` are refused.**
2. **A bare mention never wins on its own.** It expires after 30 minutes or 40 turns. The one
   exception is a session whose entire evidence names exactly one real ticket: with no competitor
   to be confused with, it holds for the session — still `medium`, because one candidate is an
   absence of ambiguity, not proof.

### Why work goes unattributed, and why that's reported

Unattributed usage is shown with its reasons, never spread across tickets — distributing it would
make every row look precise and be wrong.

Unassigned usage is **kept and counted in the totals** — it appears as its own `Unassigned` row
in the ledger, in the CLI table, and in the snapshot. It is never discarded, and never forced
onto a weak match to make coverage look better.

| Reason | What it means |
| --- | --- |
| `no-ticket-in-session` | The session never named a ticket. **A fact about how the work ran**, not a limit of the reading. |
| `before-first-signal` | Turns before the session's first ticket signal. |
| `signal-expired` | The signal went stale with several tickets in play. |

### Time

- **Working time (estimated)** — the sum of gaps between consecutive turns, each capped at 5
  minutes. A transcript timestamp records *when a turn happened*, nothing more: it cannot
  distinguish agent work from a human reading their phone. So this is an inference, it is
  labelled `(est.)` in every surface that shows it, and it drops the label only for the portion
  backed by run telemetry.
- **Elapsed** — first to last turn. Calendar time, which for a ticket picked up across three weeks
  is a very different number.
- **Cycle time** — first measured stage start to last measured stage end. Exact, and computed only
  across measured runs; mixing in an inferred timestamp would put an exact label on an estimate.

### Token categories

Five counters are tracked and shown separately everywhere — table columns, CSV, JSON, snapshot —
because they are billed and cached differently and one blended figure hides which of them a
ticket actually spent:

| Counter | Field | In the total? |
| --- | --- | --- |
| Input | `input` | yes |
| Output | `output` | yes |
| Cache read | `cacheRead` | yes |
| Cache write | `cacheWrite` | yes |
| Reasoning | `thinking` | **no** |

**`total` = input + output + cache read + cache write.** Reasoning is reported by the API under
`output_tokens_details.thinking_tokens` — it is a **subset of output**, so adding it would count
those tokens twice. It is shown in its own column and never summed in.

---

## Using it

```bash
maestro usage                     # breakdowns include model/agent/runtime/provider/stage/date
maestro usage --csv tickets       # or model | agent | runtime | provider | stage | date
maestro usage --json
maestro usage --html value.html   # a self-contained, shareable snapshot
```

In the web dashboard, the **Value** tab shows the same figures live, with a row per ticket you can expand
for its per-agent and per-model split, and CSV/JSON/snapshot export. `maestro usage`, the exports,
the snapshot and the page all render from one function — `buildUsageReport()` in
`scripts/usage-core.mjs` — so they cannot quote different numbers for the same ticket.

A `maestro run` leaves **both** a telemetry record and a transcript. The record carries its
`sessionId`, so that session is excluded from the transcript pass and the measurement wins; the
count of turns dropped that way is reported as `coverage.skippedExact` rather than silently
swallowed.

Consumers use the supported package facade rather than deep-importing scripts:

```js
import { buildUsageReport, buildPortfolioUsage, usageToCsv } from "@mychiefmind/ai-maestro/usage";
const report = buildUsageReport({ boardPath: "./board" });
```

### Filtering several dimensions safely

The public report includes `usageSlices`, a schema-versioned daily table for companion UIs:

```js
import { USAGE_SLICE_SCHEMA, USAGE_SLICE_DIMENSIONS } from "@mychiefmind/ai-maestro/usage";

report.usageSlices.schema === USAGE_SLICE_SCHEMA; // 1
report.usageSlices.dimensions;                    // the frozen eight-dimension contract
report.usageSlices.rows;                          // joint observations, not inferred combinations
```

Every row keeps the complete `projectKey + ticketId + scope + provider + model + runtime +
provenance + UTC date` tuple and a `metrics` block. This matters when one project has DeepSeek
application calls and OpenAI Codex runs: combining the older one-dimensional breakdowns would
invent combinations that never happened. The slice rows are instead built in the same pass over
the canonical observations as the totals, and all additive counters reconcile exactly.

The three scopes are `ticket`, `project-only` (an intentional application call without a ticket),
and `unassigned` (historical agent work whose ticket could not be proved). Missing provider,
model, runtime, or provenance is `unknown`. Provider counters that were unavailable are retained
with zero token counters and a positive `unavailableUsageRuns`; they are not silently discarded
or called free. `project` is a display label, while the opaque `projectKey` prevents same-named
projects and duplicate ticket ids from collapsing in a portfolio. The rows never contain raw
paths, run/event/session ids, evidence strings, prompts, responses, or transcript content.

### Recording DeepSeek application usage

Application calls are a separate append-only ledger. Maestro accepts only aggregate counters —
never API keys, prompts, responses, headers, or raw logs. Keep the key in the environment, make
the request in your application, and pass only `body.usage` through the mapper:

```js
import { appendApplicationUsage, mapDeepSeekUsage } from "@mychiefmind/ai-maestro/usage";

const response = await fetch("https://api.deepseek.com/chat/completions", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify(request),
});
const body = await response.json();

appendApplicationUsage({
  boardPath: "./board",
  event: {
    project: "my-project",
    ticketId: "T-123", // optional; omit or use null for project-only usage
    provider: "deepseek",
    model: body.model || "deepseek-chat",
    timestamp: new Date().toISOString(),
    operation: "chat-completion",
    usage: mapDeepSeekUsage(body.usage), // only the usage object crosses this boundary
    idempotencyKey: response.headers.get("x-request-id") || applicationRequestId,
  },
});
```

The idempotency key is mandatory but is stored only as a SHA-256 digest. Replaying an identical
event is a no-op; reusing its key for different counters is a typed conflict. The project must
match `config.json`, and an optional ticket must exist on the live board or in its archive.
Malformed ledger lines are skipped and counted by reports, but block subsequent writes because
exactly-once behavior cannot be proven while the ledger is ambiguous.

## Across every project

```bash
maestro usage --all --registry ~/source/maestro-registry.json
maestro usage --discover ~/source        # ad-hoc, no registry file needed
maestro usage --all --html portfolio.html
```

In the web dashboard, start with `--registry <file>` (or `MAESTRO_REGISTRY`) and the Value page gains
an **All projects** toggle: a project table, a `project` breakdown alongside the others, and the
per-ticket table spanning every board.

It is a **merge, not a second aggregation** — each project is measured by the same
`buildUsageReport()` its own page uses, and the rollup only sums the results, so a portfolio
total and a project's own page cannot disagree.

Three things the rollup has to get right, each of which a first pass got wrong:

| Trap | What goes wrong | How it's handled |
| --- | --- | --- |
| **Nested repos** | A group checkout with its own board sits inside a kit that also has one. Both match on a path prefix, so the inner project is counted twice and the outer is credited with work it never did. | Ownership goes to the **deepest** matching root; every project is reported with the others' roots as `excludeRoots`. |
| **The vendored kit dir** | `<project>/maestro` holds `board/data.json`, so it looks exactly like a project. Discovery produced 21 entries all named `maestro`. | The kit dir is **consumed** by the project that owns it and never walked into again. Discovery still descends elsewhere, so a genuinely nested board is not missed. |
| **Same-named projects** | Two groups can each hold an `app`. Keying roots by name collapses them; every duplicate inherits the last one's roots, excludes its own work, and reports a confident zero. | Roots are keyed by **path**. |

A project whose board is nothing but starter samples is **flagged, not dropped** — "this project
exists and no work has been booked to it" is a real answer, and hiding it would repeat the
mistake the unattributed panel exists to avoid.

## Limits worth knowing

- **Older Codex versions without `exec --json`** are run once in legacy mode and recorded with an
  exact duration and `usage: null` / `usageSource: "none"`. A zero would read as "this stage was
  free", which is a different and false claim.
- **Attribution improves the more the board is used.** Working on a branch named after the ticket,
  or moving it with `maestro ticket set-status`, is what turns a `medium` row into a `high` one.
