# Feature: Operational Reporting

<!-- toc -->
- [1. What is sent, and what never is](#1-what-is-sent-and-what-never-is)
- [2. When it is sent](#2-when-it-is-sent)
- [3. The reporting identity](#3-the-reporting-identity)
- [4. The server side](#4-the-server-side)
- [5. The token](#5-the-token)
- [6. Opting out, and the two silences](#6-opting-out-and-the-two-silences)
- [6b. Feedback is not telemetry](#6b-feedback-is-not-telemetry)
- [7. The half-configured case](#7-the-half-configured-case)
- [8. Where it is NOT called](#8-where-it-is-not-called)
<!-- /toc -->

Reporting needs three things: `usageLog.enabled` true, a token that resolves,
and a GitHub account on this machine that can read the pipeline repository. The token
is arranged by one call (`$HOME/.claude/scripts/usage-register.mjs`) made from
the three places a machine can first become real: **setup**, **update**, and
**the Phase 0 exit gate** of a run on a machine that reached neither. Asserted by
`smoke-usage-register.sh`.

## 1. What is sent, and what never is

`usage-report.mjs` emits coarse run metadata, one record per run, upserted by
run id. Never prompts, never code, never diffs, never token values, never
absolute paths, never the repository name (a digest stands in for it). The
registration call sends the reporting login and the short hostname, plus the
signed challenge when the account was found by an SSH key (section 4).

Each record carries:

| Field | Meaning |
|-------|---------|
| `id`, `tt` | task id and task type (`bugfix`, `feature`, ...) |
| `ev` | the event that sent it: `phase`, `completed`, `halted`, `parked` |
| `st` | run status: `in_progress`, `completed`, `halted`, `parked`, `failed` |
| `ph` | current phase, from the tracker (the phase in progress, else the last one reached) |
| `phs` | every tracker phase with status, duration in seconds and tokens |
| `rc` / `hr` | reason code: `<phase>:<cause>` for a halt, the waiting step for a park; reduced to `[a-z0-9:_-]` after paths are removed |
| `pr` | whether a PR was opened |
| `pru` | the PR URL, only when `usageLog.includePrUrl` is true (it names the repository) |
| `ti` | a one-line task title, only when `usageLog.includeTitle` is true: at most 80 characters, code spans, markup and absolute paths removed |
| `m`, `c` | mode and command. `m` is always one of the modes `schemas/phases.json` declares, or `unknown`; `c` is a command that still ships under `commands/multi-agent/`, otherwise the mode. A state written by an older version cannot put a mode name on the panel that the pipeline no longer has |
| `t`, `t0`, `du` | time of this event, run start, run duration |
| `tk`, `cost`, `models` | token totals, estimated cost, models used |
| `os`, `cli`, `lang`, `stack`, `plug`, `apps`, `creds`, `calls`, `errs`, `v` | environment and credential-health summary |

`st` comes from the event when one is given (`--event`), otherwise from the
state file: `haltReason` or `paused` is halted, `awaiting_input` or a
`waitingFor` step is parked, `complete` is completed, anything else is in
progress.

## 2. When it is sent

| Event | Where |
|-------|-------|
| `phase` | `phase-tracker.sh init` and every `update` to a status other than `pending`, detached so it never blocks a boundary; plus the Phase 0 exit gate |
| `completed` | Phase 5, after it writes `status: complete` and `finishedAt` to the state; the analysis render step |
| `halted` | the halt-visibility step in `phases/operations.md` |
| `parked` | the park-visibility step in `phases/operations.md`, after a run writes `awaiting_input` or `waitingFor` |
| `install`, `update` | registration, setup and update, and every accepted run event, when the installed version was not reported yet (see below) |

**Install and update records.** Derived, never written by the installer (see
section 8): the version stamp `install.js` writes (`.pipeline-version`, Claude
Code's first) says which version is installed and, by its modification time,
since when; prefs keep the last version reported (`usageLog.reportedVersion`).
When they differ, `usage-report.mjs --lifecycle` sends one record: `ev`
(`update` from the reported version; without one, `install` on a machine that
registered after the stamp and `update` from an unknown version on one that
registered before it), `v`, `vf`, `hs` (the host trees at that version) and `t`
(the stamp time), and moves `reportedVersion` forward once the server accepted
it. The id is derived from the version and the stamp time, so a resend replaces
the record. It runs after a successful registration, after setup or update finds
a token already onboarded, and after every accepted run event. The panel lists
the records under "Kurulumlar" and keeps them out of the run counts.

The panel derives a run's status from the last event it received. A run still
`in_progress` with no event for **6 hours** is shown as abandoned; a parked run
is waiting on a person and is never shown as abandoned. The threshold is
`STALE_RUN_MS` on the server side.

## 3. The reporting identity

The login is the GitHub login of **the account on this machine that can read the
pipeline repository** (`usageLog.accessRepo`, default `mmerterden/multi-agent-pipeline`):
access to that repository is what grants use of the pipeline, so it also decides
whose name a run carries. `usage-identity.mjs` finds it with no question asked,
stopping at the first account that can read the repository:

| Order | Method (`usageLog.loginMethod`) | Candidates | Access check |
|-------|------|------------|--------------|
| 1 | `gh` | every github.com account `gh auth status` lists: `usageLog.reportAs` (when logged in), the active account, then the rest | `gh api repos/<accessRepo>` with that account's token in the child's environment as `GH_TOKEN` |
| 2 | `credential` | the token git's credential helper holds for https://github.com: `git credential fill` with `protocol=https`, `host=github.com` on stdin, `GIT_TERMINAL_PROMPT=0`, `GCM_INTERACTIVE=never`, askpass disabled, a timeout | `GET /user` names the login, `GET /repos/<accessRepo>` proves access; through `gh api` with `GH_TOKEN`, or straight to the GitHub API from a child process when gh is not installed |
| 3 | `ssh` | the private keys `ssh -G github.com` lists (`identityfile`, `~` expanded) and `~/.ssh/id_ed25519`, `id_ecdsa`, `id_rsa`, each only when its `.pub` exists; a `.pub` listed on its own stands for a key held by an agent | `ssh -T git@github.com` names the login (`Hi <login>!`; a deploy key's `owner/repo` is skipped), `git ls-remote git@github.com:<accessRepo>.git HEAD` proves access. Both with `-i <key> -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=accept-new` |

No token is ever put on an argv, logged or stored: gh and git print it on their
own stdout, and it travels on only in a child's environment. Batch mode means a
key that needs a passphrase and is not in ssh-agent is skipped, never prompted
for. A missing gh, git or ssh binary, a timeout or a failed check only means
"no candidate"; nothing here fails a caller.

Never `identity.name`, which carries a person's real name and sometimes a
corporate title, and never simply the active `gh` account, which on a machine
with a work account and a personal account is often the one without access.

The answer is cached in prefs (`usageLog.login`, `loginMethod`, `loginKey` for
the SSH key path, `loginRepo`, `loginCheckedAt`, `loginReason`) for 7 days, or
1 day when no account was found, so a phase boundary costs no network call. A
cached SSH login whose key file is gone is re-checked. Refresh it after changing
accounts or keys, or being granted access:

```bash
node "$HOME/.claude/scripts/usage-identity.mjs" --refresh
```

**No account with access, no report.** Nothing is reported under an account
that cannot read the repository: the server accepts only the repository owner
and its collaborators. The emitter sends nothing and writes one line to stderr
(`usage-report: not sent - no GitHub account on this machine can read ...`,
naming what was tried); registration is skipped with the same reason. A cache
entry or registration written by 20.17.0 with `loginFallback` /
`registeredFallback` set is treated as no account: it is re-checked at once and
replaced as soon as an account with access is found.

**`--quiet` still speaks once.** The Phase 0 backstop discards stdout, so a
registration that leaves reporting off for a reason the user can fix (a refused
login, no account with access, a key that cannot sign, an unreachable endpoint)
prints one stderr line, `multi-agent: usage reporting is off - <reason>`, on
every run until it is fixed.

### Personal account

A registration is pinned: the server binds the token to the login it verified,
and the machine keeps that login in `usageLog.registeredAs`. Every later record
is named after it, with no identity check, and finding no account later does not
undo it (`decideRegistration`). So the personal account has to be reachable only
once, at the moment of registration.

`usage-register.mjs --json` reports `needsPersonalAccount: true` only when the
machine is not pinned and no account was found at all: no gh account, no
credential-helper token and no SSH key with access. That is the last resort.
Setup and update then ask once (AskUserQuestion per
`$HOME/.claude/multi-agent-refs/picker-contract.md`; label
`Add personal account (Recommended)` / `Skip`) and offer either fix:

- **gh**: note the active account (`gh api user --jq .login`), have the person
  run the browser login for the account that holds access in their own terminal
  (in Claude Code: `! gh auth login --hostname github.com --web`; gh keeps both
  accounts), then switch the work account back so pull requests keep their
  author: `gh auth switch --hostname github.com --user <the noted account>`.
- **SSH key**: add this machine's public key (for example `~/.ssh/id_ed25519.pub`)
  under the personal account's GitHub Settings > SSH and GPG keys. Nothing else
  changes on the machine.

Then `node "$HOME/.claude/scripts/usage-register.mjs" --refresh --json` finds
the account and pins it. Skip leaves reporting off; the next update asks again.

## 4. The server side

`/api/usage/register` requires proof that the caller owns the login it names.
The proof depends on how the account was found:

- **gh**: `gh auth token --user <login>` (gh prints it on its own stdout), sent
  as `Authorization: Bearer <token>`.
- **credential**: the credential helper's token, fetched again at registration
  (never kept from the identity check), sent the same way.
- **ssh**: no token at all. (a) `POST <register url>/challenge` with
  `{ "u": <login> }` returns `{ challenge, namespace, expiresAt }`. (b) The exact
  challenge bytes (UTF-8, no trailing newline) are written to a `0600` file in a
  fresh temp dir and signed with `ssh-keygen -Y sign -f <key> -n <namespace>`,
  stdin closed and askpass disabled, so a key that needs a passphrase fails at
  once; then with `-f <key>.pub`, which signs through ssh-agent when the key is
  loaded there. The temp dir is removed afterwards. (c) `POST <register url>`
  with `{ "u", "c", "ssh": { "challenge", "signature" } }` and no
  Authorization header.

For a bearer, the server resolves the token to a GitHub login and refuses a
mismatch. For an SSH proof, the challenge is stateless and HMAC-bound to the
login, valid for five minutes; the server checks it, then accepts the signature
only from one of the login's public keys on GitHub
(`https://github.com/<login>.keys`; ed25519, rsa-sha2-256/512, ecdsa nistp256). Either way it then checks that login with a
server-side token (the repository owner, or a collaborator of the repository)
before it issues a token, and pins the login to that token. The body's `u` is a
hint for a bearer and the claim being proven for a signature.

The proof is sent only over https to the default reporting host or a host listed
in `usageLog.trustedHosts`, or plain http to loopback (`127.0.0.1`, `localhost`,
`[::1]`) for local development; any other endpoint is refused before any token
is read or any key is used. Redirects are not followed by any of the calls, so
neither a GitHub token, a signature nor the ingest token can be carried to
another host.

| Answer | Status line |
|--------|-------------|
| `401` / `403` `login_mismatch` | the login that could not be confirmed (for gh: `gh auth status` must list it) |
| `401` `ssh_signature_invalid` | the signature did not verify |
| `401` `ssh_key_not_on_account` | the key is not on that account; add its `.pub` to it |
| `401` `ssh_key_unsupported` | the key type is not accepted; use an ed25519, rsa or ecdsa-p256 key |
| `403` (no access) | the login needs collaborator access |
| challenge `404` / `405` | the endpoint does not accept an SSH key as proof yet |
| signing failed | the key needs a passphrase and is not in ssh-agent: `ssh-add <key>` |
| `accepted: false` | `declined` |

gh or the credential helper having no token for the login sends nothing.
`/api/usage/ingest` reports every record under the pinned login and ignores the
name the client sent; a pinned login that is no longer the owner or a
collaborator is dropped. Records from any other account are hidden from the
panel. `usage-register.mjs` replaces a stored token whose `registeredAs` differs
from the verified login, that predates verification, or that a 20.17.0 fallback
registration left, because a token reports under the login it was issued for.

## 5. The token

Requested, never shipped. `/register` mints a per-machine **write-only** token:
append-only to the ingest endpoint, no read access, no other scope. Only its
sha256 hash is stored server-side, so a database leak exposes no usable
credential, and the owner can revoke one row without touching anyone else.

It lands in the OS credential store under `<user>_Usage_Ingest_Token`. Prefs hold
the NAME of that entry (`keychainMapping.usage_ingest`) and the on-switch, never
the secret. Every prefs write here (the cached login, the entry name, the
switch) holds the prefs file's lock and replaces the file atomically at mode
`0600`, the same helper `migrate-prefs.mjs` uses. Resolution order at emit time: `$MULTI_AGENT_USAGE_TOKEN`, then
`usageLog.token`, then the credential-store entry.

## 6. Opting out, and the two silences

`usageLog.optOut: true` blocks registration permanently and is checked before
anything else - before the identity check, the network call and any credential
read (the credential store, `gh auth token`, `git credential fill` and any SSH
key).

The other silence is not a choice: offline, endpoint down, ingest disabled by the
admin, no account with access, or a credential store that refuses the write. That
leaves reporting off with one status line and exit 0. **A caller is never failed
over bookkeeping**, which is the same rule the capture hooks follow.

Both are reported distinguishably (`--json` gives `status`: `skipped` with the
reason, `unavailable` with the cause, `declined`, `registered`, `enabled`, `dry-run`) because
"you turned it off" and "we could not reach the endpoint" are different facts
about the same empty panel.

Every send attempt past the on-switch overwrites
`~/.claude/logs/multi-agent/usage-last-send.json` with its outcome: `accepted`,
`not-accepted` (the server answered but did not keep the record), `rejected`
(with the status code), `redirected` (with the host it pointed to; a redirect is
never followed, because the token would ride along), `failed` (network or
timeout), or `skipped` (`no-token`, `no-login`, `endpoint-not-allowed`). It
carries the run id, the event and the endpoint host, never the token. The
phase-tracker ping discards the emitter's output, so `/multi-agent:doctor`
reads this file (check `usage-report`) and turns anything but `accepted` into a
WARN with one step. `migrate-prefs.mjs` rewrites an endpoint on the platform
host to the site's domain, so a stored redirecting endpoint does not outlive an
update.

`prefs.global.usageLog.endpoint` overrides where both calls go - the register URL
is derived from it, so a self-hosted ingest gets its own registration rather than
this one's. Absent means the shipped default, and every shipped default names the
same host, so a machine never registers against one host and reports to another.

## 6b. Feedback is not telemetry

`/multi-agent:feedback` rides the same token, and `optOut` does not silence it:
passive collection is a choice, a message somebody typed to be read is not. So a
feedback run may register (`--feedback`) on a machine that opted out - and when it
does, it writes the credential-store entry and **leaves `usageLog.enabled` alone**.
The opt-out still holds for everything it was about; the person just gets their
message delivered.

## 7. The half-configured case

A token in the credential store with `enabled: false` produces exactly the same
silence as no token at all, and it happens whenever a run is interrupted between
the two writes. The call repairs it: when a token already resolves but the switch
is off, it turns the switch on and says so rather than reporting "unchanged".

## 8. Where it is NOT called

The installer. `install.js` lays down files and nothing else; seeding state is the
one thing the install contract forbids, and a fresh machine has no preferences
file for the registration to write into. Setup creates it; registration follows.
