diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 384fb62a9..0ca3be009 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -87,10 +87,11 @@ The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at in | `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 | +| `filters` | `task`, `ticket`, `person`, `model`, `agent`, `session`, `repository`, `absent`, `unattributed`, in that order: `null` when not given, else as resolved (see [Filters](#filters)) | +| `rows` | one per axis value, of the calls that match | +| `totals` | the calls of the period that match | | `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` | +| `coverage` | `files_read`, `records`, `unrecognised_shapes`, `not_stored`, `consent_log_damaged`, `oldest_transcript_at`, `skipped_ledger_lines`. `records` counts the calls of the period, matching or not | Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (each `{tokens, unknown_records}`), `total`. A row also has: @@ -106,6 +107,34 @@ Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (eac - A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of one call have been observed less than a day apart, and under that bound both print the same figures. Outside it, only a report with no period folds every copy of a call. `skipped_ledger_lines` counts the lines of the partitions read. - No path, working directory or branch. `session` keys are full session ids, `repository` keys are hashes. +### Filters + +Every row a report prints can be selected, and the report it selects adds up to that row. A filter keeps a call when the axis it names gives that call exactly the value named, by the same function that builds the rows. A `day` row is selected by the period, `--from D --to D`; the `total` row by no filter. + +| Flag | Keeps the calls whose | Value | +| --- | --- | --- | +| `--task ` | `task` is that value | a task name | +| `--ticket ` | `ticket` is that value | a ticket, as declared | +| `--person ` | `person` is that value | the identity | +| `--model ` | `model` is that value | a model id | +| `--agent ` | `agent` is that value | `main`, `subagent`, `advisor` | +| `--session ` | `session` is that value | a session id, or a start of one | +| `--repository ` | `repository` is that value | a repository id, a start of one, or `.` | +| `--absent ` | axis has no value | `person`, `model`, `ticket` | +| `--unattributed [reason]` | `task` and `ticket` are unattributed | `no-binding`, `declared-none`, or `any` (the default) | + +- Flags combine with AND. Each takes one value; given twice, exit 1. +- One selector per axis: `--unattributed` selects on `task` and `ticket`, so it cannot be combined with `--task`, `--ticket` or `--absent ticket`; nor can a value and `--absent` of the same axis. Exit 1. +- A value is trimmed as a declaration is; empty once trimmed, exit 1. It is compared exactly, case included. +- A value outside a closed set (`--agent`, `--absent`, `--unattributed`) exits 1. A value of an open set that no call has gives an empty report, exit 0. +- `--session` and `--repository` resolve among the values of that axis in the calls of the period: a value equal to the one typed wins, else the one value it starts. Two or more: exit 1, listing them in full, sorted. None: an empty report, and `filters` prints the value as typed. +- `--repository .` names the repository the command runs in, by the rule ingest uses: its hosted `origin`, else its first commit. A linked worktree names its main tree's repository. Not a repository, gone, unreadable, or with neither: exit 1. It is compared in full, never as a start. +- Order: the flags are checked, then `AIDD_TELEMETRY=0` refuses (no git is run, the envelope has no `filters`), then `.` is located, then transcripts are ingested and the ledger read, then starts are resolved. Filters apply after attribution and after the period; the window a bounded report reads is unchanged. +- A task name belongs to no repository: `--task refactor` adds up every repository's `refactor`, as `--axis task` does. Add `--repository .` for one. +- The text report folds whitespace in a name onto one line. A filter compares with the stored value, which `--json` prints as `key`. +- `--repository .` names the repository as it stands now. Calls stored under an earlier `origin`, or before a remote was added, carry another id and are not selected; `--axis repository` lists them. +- `--person` is the identity of the machine reporting, as `--axis person` is: locally it selects every call or none. + ## 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. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-filters/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/contract.md new file mode 100644 index 000000000..c26a7a8f6 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/contract.md @@ -0,0 +1,92 @@ +--- +status: done +issue: 1030 +--- + +# The report answers about one task, ticket, person or more + +## Need + +`aidd telemetry report` splits every call of a period along one axis (#1030). It cannot answer "what did task `auth-refactor` cost, per day" or "what did this repository cost, per model": every question about one value of an axis is answered by reading one row of a split, and no question crosses two axes. + +## Principle + +**Every row a report prints can be selected, and the report it selects adds up to that row.** + +A row is one `AxisValue` (`domain/report/usage-report.ts`): a `value`, `absent` (the axis has no value for the call), or `unattributed` with a `reason`. The filters use the same three kinds, and a call matches a filter when `axisValueOf` gives it exactly the value the filter names. A filter and a row therefore cannot disagree on what a call is: they are worked out by the same function. + +Two axes have no flag. A `day` row is selected by the period, `--from D --to D`. The `total` row is the whole report. Every other axis has one. + +```mermaid +flowchart LR + P["calls of the period"] --> A["attribute each call"] + A --> F["keep the calls every filter matches
(axisValueOf = the value named)"] + F --> R["buildReport on one axis"] +``` + +## Surface + +| Flag | Selects, on axis | Value | +| --- | --- | --- | +| `--task ` | `task` = value | a task name | +| `--ticket ` | `ticket` = value | a ticket, as declared | +| `--person ` | `person` = value | the identity | +| `--model ` | `model` = value | a model id | +| `--agent ` | `agent` = value | `main`, `subagent`, `advisor` | +| `--session ` | `session` = value | a session id, or a prefix of one | +| `--repository ` | `repository` = value | a repository id, a prefix of one, or `.` | +| `--absent ` | that axis = `absent` | `person`, `model`, `ticket` | +| `--unattributed [reason]` | `task` and `ticket` = `unattributed` | `no-binding`, `declared-none`, or `any` (the default) | + +`--absent` offers only the axes a call can have no value for: `model` is `null` when the tool does not say, `person` when no identity is set, `ticket` when a task was declared without one. `session`, `agent` and `repository` are never `null` in the ledger (`stored-usage.ts`, `usage-record.ts`), and `task` has no `absent`: work with no task is `unattributed`. + +## Rules + +| # | Rule | Why | +| --- | --- | --- | +| 1 | Flags combine with AND. Each flag takes one value; given twice, it is an error. | Commander keeps the last value silently. | +| 2 | **One selector per axis.** `--unattributed` selects on `task` and on `ticket`, so it cannot be combined with `--task`, `--ticket` or `--absent ticket`; a value and `--absent` of the same axis (`--model m --absent model`) cannot be combined either. Such a combination is an error, exit 1. | Two selectors on one axis select nothing, by construction. | +| 3 | A value is trimmed as a declaration is (`textOrNull`, `task-declaration.ts`). A value empty once trimmed is an error. | What is typed is compared with what was stored, and what was stored was trimmed. | +| 4 | A value is compared exactly, case included. | Task names and tickets are kept as typed. | +| 5 | `--session` and `--repository` take a full value or a prefix, looked up among the values of that axis in the calls of the period. A value equal to the one typed wins. Otherwise a prefix names the one value that starts with it. Two or more: error, exit 1, listing them in full, sorted. None: the report is empty, and `filters` prints the value as typed. | The text report shows 8 characters of each; that is what a person copies. | +| 6 | `--repository .` names the repository of the directory the command runs in, by the rule ingest uses (`RepositoryLocator.locate`, then `repositoryIdOf`). Not a repository, gone, unreadable, or with neither a hosted `origin` nor a commit: error, exit 1. It is resolved after the refusal and before ingest (rule 9). | A repository is named by its hosted address else its first commit; a worktree and another clone of the same `origin` name the same repository. | +| 7 | A value outside a closed set is an error (`--agent`, `--absent`, `--unattributed`, by Commander's `choices`). A value of an open set that no call has gives an empty report, exit 0. | A value that cannot exist is a mistake; one that does not exist this period is an answer. | +| 8 | Filters apply after attribution and after the period. The ledger window of #1032 is unchanged. | Attribution is per call, so a filter changes no call's task or ticket. | +| 9 | Order: the flags are checked (rules 1, 2, 3, 7), then `AIDD_TELEMETRY=0` refuses, then `.` is resolved, then transcripts are ingested and the ledger read, then prefixes are resolved. Under `AIDD_TELEMETRY=0` the envelope stays `{"version": 1, "refused": "AIDD_TELEMETRY=0"}`, with no `filters`, and git is not run. | Refusal means nothing is read, a repository included. | + +## Output + +**Envelope**, still `version: 1`. One field is added, `filters`, always present, its keys in this order: `task`, `ticket`, `person`, `model`, `agent`, `session`, `repository`, `absent`, `unattributed`. Each is `null` when the flag is not given, else the value **as resolved**: the full id for a prefix or `.` (the prefix as typed when it matches nothing), the axis for `absent`, the reason or `any` for `unattributed`. Every value it prints can be typed back. + +| Field | With filters | +| --- | --- | +| `rows`, `totals`, `unknown_records` | the calls of the period that match | +| `coverage.records` | unchanged: the calls of the period, matching or not | + +**Text.** When a filter is given, one line before the table names each, as its row would label it. When no call matches, the text says so, and the exit code is 0. + +## Unchanged + +The period, the ledger window and settle of #1032, attribution, every axis, the row order, and every envelope field already printed. A report with no filter prints what it prints today, plus `filters` with every key `null`. + +## Limits, written in the usage contract + +- A task name belongs to no repository: `--task refactor` adds up every repository's `refactor`, as `--axis task` does. Add `--repository .` for one. +- The text report folds whitespace in a name onto one line. A filter compares with the stored value, which `--json` prints as `key`. +- `--repository .` names the repository as it stands now. Calls stored under an earlier `origin`, or before a remote was added, carry another id and are not selected; `--axis repository` lists them. +- `--person` is the identity of the machine reporting, as `--axis person` is: locally it selects every call or none (#1029). + +## Acceptance + +| # | Criterion | Proof | +| --- | --- | --- | +| 1 | For any ledger, period, axis and row of that report, the report filtered to that row (a `day` row by its period, the `total` row by no filter) has `totals` equal to that row, and every axis of it adds up to its `totals` | fast-check property on the use case | +| 2 | Filters commute and combine with AND: the calls kept are those every filter alone keeps | fast-check property | +| 3 | A bounded and filtered report equals the unbounded report with the same filters, restricted to the period. Prefixes are resolved against the period's calls on both sides | extends the #1032 property | +| 4 | Every error of rules 1, 2, 3, 5, 6 and 7 exits 1 with a message naming the flag; those of rules 1, 2, 3, 6 and 7 before any transcript or ledger is read | command and use-case tests | +| 5 | `.` resolves, in a linked worktree and in the main tree, to the id ingest stored for that repository | integration test on a real git repository | +| 6 | No filter: the envelope equals today's, plus `filters` with every key `null` | test | +| 7 | Tests are written first, and their red run is recorded | `evidence.md` | +| 8 | Telemetry mutation stays at 96 or above, and every mutant on a changed line is killed or explained | stryker | +| 9 | Usage contract, `cli/aidd_docs/memory/telemetry.md` and the `01-usage` skill (`frame`, `envelope.md`) say what a filter selects | grep, plus a fresh checker | +| 10 | `pnpm smoke` and `scripts/check-claude-tokens-end-to-end.cjs`, both modes, pass; the end-to-end also runs one filtered report | output in `evidence.md` | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-filters/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/evidence.md new file mode 100644 index 000000000..6db2a8884 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/evidence.md @@ -0,0 +1,277 @@ +# Evidence + +## Red runs + +### Phase 1 + +`pnpm exec vitest run tests/contexts/telemetry/domain/report/report-filter.unit.test.ts`, against `report-filter.ts` holding only its types and axis lists: + +```txt +× keeps every call when no flag is given +× keeps a call whose axis has exactly the value named, case and all +× are --unattributed with --task, --ticket or --absent ticket +× is the value equal to it, even when it also starts another +× keeps exactly the calls of that row, so it adds up to that row +TypeError: matchesFilter is not a function +TypeError: conflictsOf is not a function +TypeError: resolveId is not a function +Tests 15 failed (15) +``` + +#### Each guard, broken + +Each mutation reverted after its run. + +`--absent` keeping every call (`if (selector.kind === "absent") return true;`): + +```txt +× keeps a call the axis has no value for under --absent, and only that call +× keeps a call only when every flag given keeps it +× keeps exactly the calls of that row, so it adds up to that row +Error: Property failed after 28 tests +``` + +`resolveId` without the exact match first: + +```txt +× is the value equal to it, even when it also starts another +``` + +`--unattributed` occupying `task` only (`? ["task"] :`): + +```txt +× are --unattributed with --task, --ticket or --absent ticket +× lists every pair, each in the order the flags are printed +``` + +`--unattributed any` matching one reason only (`selector.reason === "any" ||` removed): + +```txt +× keeps work with no task by its reason, or by either under any +× keeps a call only when every flag given keeps it +``` + +### Phase 2 + +`pnpm exec vitest run tests/contexts/telemetry/application/report tests/presentation/display/telemetry`, before the use case took filters (the constructor ignored the locator and the refusal it was given): + +```txt +report-envelope.unit > names what the report was narrowed to, every flag in a fixed order, as resolved + AssertionError: expected [ 'version', 'period', 'axis', 'rows' ] to deeply equal [ 'version', 'period', 'axis', …(1) ] +report-usage-use-case.unit > narrowing a report > (13 cases) + AssertionError: expected { status: 'reported', …(3) } to deeply equal { status: 'ambiguous', …(3) } + AssertionError: expected { status: 'reported', …(3) } to deeply equal { status: 'not-a-repository' } + Tests 14 failed | 19 passed (33) +report-filters.property > adds up to the row it selects, and every axis of it adds up to its totals + Error: Property failed after 6 tests + AssertionError: expected { records: 2, … } to deeply equal { records: 1, … } +report-window.property > prints the envelope of the full ledger filtered to its period and its filters, byte for byte, … + Error: Property failed after 1 tests +telemetry-report-display.unit > names the flag and every id a start names, in full, when it names several +telemetry-report-display.unit > says `.` names no repository where the command runs + TypeError: Cannot read properties of undefined (reading 'from') +``` + +Green before the change, by construction, so not red runs: "refuses under AIDD_TELEMETRY=0 before `.` runs git" (ingest refused first, and nothing ran `.`), and the AND property (a report that ignores every filter keeps the same calls whatever is combined). + +#### Each guard, broken + +The report's own refusal removed, leaving only ingest's: + +```txt +× reads nothing and reports nothing under AIDD_TELEMETRY=0 +× refuses under AIDD_TELEMETRY=0 before `.` runs git +AssertionError: expected { status: 'reported', …(4) } to deeply equal { status: 'refused' } +``` + +`matchesFilter` keeping a call when any filter keeps it (`.every(` to `.some(`): + +```txt +× keeps the calls every one of its filters keeps alone +Error: Property failed after 1 tests +``` + +A start resolved among every call read, the day on each side of the period included (`resolvedIn(held.records, "session"`): + +```txt +× prints the envelope of the full ledger filtered to its period and its filters, byte for byte, … +Error: Property failed after 29 tests +× resolves a start among the calls of the period only +AssertionError: expected { status: 'ambiguous', …(3) } to match object { status: 'reported', …(1) } +``` + +The unit case did not see this mutation at first: its out-of-period call sat outside the window read. It now sits on the day before the period, which the window reads to settle. + +Acceptance 5 (`.` on a real repository, main tree and linked worktree) is proven against the built binary in phase 3, where ingest really stores the id: the use case's own cases use the locator port. + +### Phase 3 + +`pnpm exec vitest run tests/presentation/commands/telemetry-wiring.integration.test.ts`, before the flags existed: + +```txt +× asks for every day on the total axis when nothing is given, and prints text + AssertionError: expected "vi.fn()" to be called with arguments: [ { axis: 'total', …(2) } ] +× passes every flag it is given, trimmed as a declaration is + CommanderError: error: unknown option '--task' +× reads a bare --unattributed as either reason, and takes one reason + CommanderError: error: unknown option '--unattributed' +× refuses ["--model","m","--absent","model"], which select on one axis, with exit 1 before reading anything + CommanderError: error: unknown option '--model' +× exits 1 when the report cannot name what was asked: {"status":"not-a-repository"} +Tests 11 failed | 39 passed (50) +``` + +`pnpm exec vitest run tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts`: + +```txt +× names each filter as its row would label it, on one line before the table + AssertionError: expected [ …(3) ] to deeply equal [ …(3) ] +× says no call matches when filters keep none, and still shows the coverage + AssertionError: expected [ …(4) ] to deeply equal [ …(5) ] +Tests 2 failed | 19 passed (21) +``` + +`pnpm exec vitest run --project=e2e tests/e2e/telemetry-report.e2e.test.ts`, against the built binary: + +```txt +× adds up to the row it selects, a session typed by the eight characters the text shows + AssertionError: error: unknown option '--ticket' +× names with `.` the repository ingest stored, from the main tree and from a linked worktree + AssertionError: error: unknown option '--repository' +× refuses a start two sessions share, naming both in full and sorted +× refuses flags on one axis with exit 1, naming the flag, and reads nothing +× refuses `.` outside any repository with exit 1, and reads nothing +× runs no git for `.` under AIDD_TELEMETRY=0 + AssertionError: expected 1 to be +0 // Object.is equality +Tests 7 failed | 18 passed (25) +``` + +`node --test scripts/__tests__/check-claude-tokens-end-to-end.test.js`: + +```txt +✖ fails when the report narrowed to the task in this repository misses a call +✖ fails when `.` is not resolved to the repository's id +``` + +Green before the change, by construction, so not red runs: the refusals of a repeated flag, an empty value and an unknown choice (Commander refused the unknown option with exit 1, naming it). Each is proven a guard below. + +#### Each guard, broken + +The repeat check removed from the flag parser: + +```txt +× refuses an open flag given twice with exit 1, naming the flag, before reading anything +× refuses a closed flag given twice with exit 1, naming the flag, before reading anything +× refuses a bare flag given twice with exit 1, naming the flag, before reading anything +``` + +An empty value passed through (`if (text === null) return value;`): + +```txt +× refuses a value empty once trimmed with exit 1, naming the flag, before reading anything +``` + +The choice check removed: + +```txt +× refuses an agent no call can have with exit 1, naming the flag, before reading anything +× refuses an axis no call lacks with exit 1, naming the flag, before reading anything +× refuses a reason no call has with exit 1, naming the flag, before reading anything +``` + +The conflict check skipped (`if (false)`): + +```txt +× refuses ["--unattributed","--task","a"], which select on one axis, with exit 1 before reading anything +× refuses ["--ticket","T","--unattributed","no-binding"], ... +× refuses ["--absent","ticket","--unattributed"], ... +× refuses ["--model","m","--absent","model"], ... +``` + +### Phase 4 + +The one assertion added in this phase, the ambiguity message for a repository, was written after mutation found the case uncovered (`"repositories"`: NoCoverage; `flag === "session"` to `true`: Survived). Both are killed in the second run below. + +## Gates + +| Gate | Command | Decisive line | +| --- | --- | --- | +| CLI suite | `cd cli && pnpm test` | `Tests 6575 passed \| 1 skipped (6576)` | +| Telemetry mutation | `pnpm test:mutation:telemetry` | first run `score 96.5, floor 96`; after the added test `score 96.2, floor 96` | +| Build and bundle budget | `pnpm build` | `Bundle size: 689.2 KB / budget: 695 KB` | +| Smoke | `bash scripts/smoke-tools.sh` against the build, in a relocated HOME | `PASS: 132 FAIL: 0 SKIP: 1 · coverage 100%` | +| Tokens end to end | `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository` | +| Tokens end to end, attended | same, `--attended` | `…, and on task demo in this repository; asked, answered by the hook, kept across /clear` | +| Pre-commit | `pnpm exec lefthook run pre-commit` | every job `✔️`, `cli-architecture`, `cli-biome`, `cli-typecheck`, `scripts-tests` included | +| Pre-push | `pnpm exec lefthook run pre-push` | `✔️ cli-knip`, `✔️ cli-test` (`Tests 6575 passed \| 1 skipped (6576)`) | + +Smoke and both token checks ran through a script setting `HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR` and `CLAUDE_CONFIG_DIR` to a fresh temp directory, with `GIT_CONFIG_NOSYSTEM=1`. The e2e tests, the help golden update and `cli-test` ran the build through `tests/e2e/helpers.ts`, which relocates `HOME`, `XDG_CONFIG_HOME` and `AIDD_USER_CONFIG_DIR` (the report e2e also sets `AIDD_TELEMETRY_DIR` and `CLAUDE_CONFIG_DIR`, and its git runs under `sandboxGitEnv`), but does not set `GIT_CONFIG_NOSYSTEM` for the binary. + +Between the two runs, survived and uncovered mutants in the four report files this change writes went from 13 to 10, so the drop from 96.5 to 96.2 lies in other files; the runs were not compared file by file, and the second overwrote the first report. + +### Surviving mutants on changed lines + +All equivalent: + +| Where | Mutant | Why nothing can kill it | +| --- | --- | --- | +| `report-usage-use-case.ts:158` | `typed === null` to `false` | `resolveId(null, …)` finds no id equal to `null` nor starting with `"null"`, returns `none`, and `{ id: typed }` is `{ id: null }` again | +| `report-filter.ts:81` | the selector's `kind: "unattributed"` to `""` | `selects` tests `value`, then `absent`, and treats every other kind as unattributed | +| `report-filter.ts:87` | `value.kind === "value"` to `true` | a row value of another kind has no `value`, so `value.value === selector.value` is false all the same | +| `telemetry-report-display.ts:66` | `kind: "value"` to `""` | `labelOf` tests `absent`, then `unattributed`, and labels every other kind as a value | +| `telemetry-report-display.ts:73` | `labelOf("task", …)` to `labelOf("", …)` | for an unattributed value `labelOf` reads only the reason; the axis matters for `absent` and for truncating a session or repository | + +The other survivors in the touched files (`telemetry-report-display.ts:111`, `:122`, `:189`) are on lines this change did not write. + +## Review round 1 + +Tests first, against the code of `6651b9f7`: + +```txt +report-usage-use-case.unit > refuses flags that select on one axis, before refusal and before anything is read + AssertionError: expected { status: 'reported', …(4) } to deeply equal { status: 'conflicting', …(2) } +report-usage-use-case.unit > names the repository a directory is in for `.`, … (and the five other `.` cases, now given as `here`) + AssertionError: expected { status: 'reported', …(4) } to deeply equal { status: 'not-a-repository' } +telemetry-wiring.integration > asks for the repository the command runs in for `.` + AssertionError: expected { task: null, ticket: null, …(7) } to deeply equal { task: null, ticket: null, …(8) } +telemetry-wiring.integration > refuses a bare flag given twice by its name, not by the value it was preset to + AssertionError: expected 'error: option \'--unattributed [reaso…' to contain '--unattributed is given twice: give i…' +telemetry-report-display.unit > names the two flags that select on one axis, with the axis --absent names + TypeError: Cannot read properties of undefined (reading 'from') +Tests 10 failed | 128 passed (138) +check-claude-tokens-end-to-end > passes when `.` names a repository with no hosted origin by its root commit + ✖ fail 1 +``` + +The selection line now reads `selectorsOf`, the list the filter matches with: a refactor, green before and after (the display's selection cases are unchanged). + +### Gates after the review + +| Gate | Decisive line | +| --- | --- | +| `cd cli && pnpm test` | `Tests 6578 passed \| 1 skipped (6579)` | +| `pnpm test:mutation:telemetry` | `score 96.3, floor 96` | +| `pnpm build` | `Bundle size: 689.4 KB / budget: 695 KB` | +| tokens end to end | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository` | +| tokens end to end, `--attended` | same, then `; asked, answered by the hook, kept across /clear` | + +Survivors on changed lines: `report-usage-use-case.ts:168` (`typed === null` to `false`) and `report-filter.ts:92` (`value.kind === "value"` to `true`), both equivalent as explained above. The two earlier equivalents on the selector's and the label's `kind` are gone with the `unattributed-any` selector and `selectorsOf`. The other survivors in the touched files (`telemetry-report-display.ts:112`, `:123`, `:194`) are on lines this change did not write. + +## Review round 2 + +Tests first, against `c790dd82`: + +```txt +telemetry-wiring.integration > exits 1 on ["--model","m","--absent","model"], which select on one axis, naming both as the report refuses them (and the three other pairs) + AssertionError: expected "vi.fn()" to be called 1 times, but got 0 times + Tests 4 failed | 47 passed (51) +pnpm typecheck + report-usage-use-case.unit.test.ts(528,5): error TS2578: Unused '@ts-expect-error' directive. +``` + +The command no longer checks the flags itself: the use case's `conflicting` result is the one refusal, still before refusal and before anything is read (`report-usage-use-case.unit.test.ts`, and the e2e "flags on one axis" case, which checks the telemetry directory is unchanged). `RequestedFilters` is a union: `here` comes only with `repository: null`, and a repository typed beside it does not compile. + +| Gate | Decisive line | +| --- | --- | +| `cd cli && pnpm test` | `Tests 6579 passed \| 1 skipped (6580)` | +| telemetry mutation | not rerun: the only mutated file changed, `report-usage-use-case.ts`, changed a type alone, which holds no mutant; the command is outside the scope. Last score `96.3` | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-1.md new file mode 100644 index 000000000..79191a6ac --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-1.md @@ -0,0 +1,37 @@ +--- +status: done +--- + +# Instruction: A filter selects what a row holds + +## Architecture projection + +```txt +cli/src/contexts/telemetry/domain/report/ +├── usage-report.ts ✏️ export axisValueOf; buildReport unchanged +└── report-filter.ts ✅ ReportFilter, FILTER_AXES, ABSENT_AXES, conflictsOf, matchesFilter, resolveId +cli/tests/contexts/telemetry/domain/report/ +└── report-filter.unit.test.ts (+ property) ✅ +``` + +## Tasks + +### `1)` Red first + +1. Unit tests: each selector kind (value, absent, unattributed with reason, unattributed any); AND of several; conflicts (`unattributed`+`task`, `unattributed`+`ticket`, `unattributed`+`absent ticket`) and non-conflicts (`task`+`ticket`, `task`+`absent ticket`); `resolveId`: exact wins over prefix (a 40-char value that prefixes a 64-char one), unique prefix, ambiguous returns every candidate sorted, none returns the typed value unmatched. +2. Property: for random records and declarations, every row of `buildReport(axis)` for a filterable axis equals `buildReport` of the matching records' totals. +3. Run, record red output in `evidence.md`. + +### `2)` Code + +1. `ReportFilter`: per axis an `AxisValue` to match (`unattributed` may carry `any`). No other shape. +2. `matchesFilter(filter, record, attribution, person)`: every selector equals `axisValueOf(axis, …)`; `any` matches either reason. +3. `conflictsOf(filter)`: the pairs rule 2 of the contract names. +4. `resolveId(typed, values)`: `{exact|unique: id} | {ambiguous: ids[]} | {none}`. + +## Acceptance + +| Task | Criterion | +| ---- | --------- | +| 1 | tests fail first for the reason named | +| 2 | they pass; no existing test changes what it asserts | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-2.md new file mode 100644 index 000000000..827c0d2f9 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-2.md @@ -0,0 +1,42 @@ +--- +status: done +--- + +# Instruction: The report use case narrows, resolves and refuses in order + +## Architecture projection + +```txt +cli/src/contexts/telemetry/ +├── application/report/report-usage-use-case.ts ✏️ request.filters; order of contract rule 9; results `ambiguous`, `not-a-repository` +├── application/report/report-envelope.ts ✏️ `filters`, fixed key order, resolved values +└── infrastructure (deps wiring) ✏️ pass RepositoryLocator and refusedByEnvironment +cli/tests/contexts/telemetry/application/report/ +├── report-filters.property.integration.test.ts ✅ drill-down incl. day by period and total; bounded+filtered == full filtered +└── report-usage-use-case.unit.test.ts ✏️ order, refusal runs no git, `.` errors, prefixes +``` + +## Tasks + +### `1)` Red first + +1. Drill-down property over the use case and envelope: any ledger, period, axis, row => filtered `totals` equal the row, every axis of it sums to its `totals`; `day` via `--from D --to D`, `total` via no filter. +2. AND property: calls kept = intersection of each filter alone. +3. Bounded+filtered == unbounded+filtered restricted to period (prefixes resolved on the period both sides; reuse the #1032 property generator). +4. Order: refused => locator never called, envelope has no `filters`; `.` outside a repository / gone / unreadable / no id => `not-a-repository` before ingest; ambiguous prefix => `ambiguous` with sorted full candidates. +5. No filter: envelope equals today's plus `filters` all `null`; `coverage.records` = calls of the period, unfiltered. +6. Integration: `.` in a real git repository's main tree and a linked worktree gives the id ingest stores. +7. Record red output. + +### `2)` Code + +1. `ReportRequest.filters` with `repository` possibly `{ here: dir }`. +2. Rule 9 order; prefix resolution against the period's records; then `matchesFilter`. +3. Envelope `filters`. + +## Acceptance + +| Task | Criterion | +| ---- | --------- | +| 1 | red first | +| 2 | green; #1032 property still green | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-3.md new file mode 100644 index 000000000..ac2b1e667 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-3.md @@ -0,0 +1,34 @@ +--- +status: done +--- + +# Instruction: The command and the text say what was selected + +## Architecture projection + +```txt +cli/src/presentation/commands/telemetry.ts ✏️ nine flags, one parser each (choices + no repeat), trim, conflicts, exit codes +cli/src/presentation/display/telemetry/telemetry-report-display.ts ✏️ header line of filters; "no call matches" message +cli/tests/… command and display tests ✏️ +scripts/check-claude-tokens-end-to-end.cjs ✏️ one filtered report +``` + +## Tasks + +### `1)` Red first + +1. Each error exits 1 naming the flag: repeated flag (open and closed sets), value empty once trimmed, unknown choice, `--unattributed` with `--task`/`--ticket`/`--absent ticket`, ambiguous prefix (candidates listed), `.` outside a repository. +2. `--unattributed` bare => `any`; `--unattributed any|no-binding|declared-none` accepted. +3. Text: header names each filter as its row labels it; no match => message, exit 0. +4. Record red output. + +### `2)` Code + +Thin wrapper per `cli/.claude/skills/command`: parse, guard, one use case, display. + +## Acceptance + +| Task | Criterion | +| ---- | --------- | +| 1 | red first | +| 2 | green; help text lists the flags | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-4.md new file mode 100644 index 000000000..5d3dd02be --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/phase-4.md @@ -0,0 +1,19 @@ +--- +status: done +--- + +# Instruction: Docs, skill and gates + +## Tasks + +1. `aidd_docs/product/usage-contract.md` Report envelope: `filters`, the selection rules, the limits of the contract. +2. `cli/aidd_docs/memory/telemetry.md`: where filters live. +3. `plugins/aidd-telemetry/skills/01-usage/actions/frame.md`: a value fixed on one axis becomes a filter (`--task X --axis day`); two open axes still need one report per axis; reword step 3 and the "Tasks per day" test; add tests for a filtered question. `references/envelope.md`: `filters`. +4. Gates: `cd cli && pnpm test`; `pnpm test:mutation:telemetry` >= 96, survivors on changed lines explained; `pnpm smoke`; `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` and `--attended`; bundle budget; lefthook pre-commit and pre-push. Outputs in `evidence.md`. + +## Acceptance + +| Task | Criterion | +| ---- | --------- | +| 1-3 | grep finds `filters` and each flag in all three; checker agrees | +| 4 | every gate green, outputs recorded | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-filters/plan.md b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/plan.md new file mode 100644 index 000000000..8e8fc9abb --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/plan.md @@ -0,0 +1,32 @@ +--- +objective: "A report can be narrowed to the calls of one task, ticket, person, model, agent, session or repository, and every row it prints can be selected that way." +status: done +--- + +# Plan: The report takes filters + +## Overview + +| Field | Value | +| ---------- | ----------------------- | +| **Goal** | `--task X --axis day` answers "what did task X cost per day"; a filter adds up to the row it selects | +| **Source** | [`contract.md`](./contract.md), issue #1030 | + +## Phases + +| # | Phase | File | +| --- | ------------ | ---------------------------- | +| 1 | A filter selects what a row holds | [`phase-1.md`](./phase-1.md) | +| 2 | The report use case narrows, resolves and refuses in order | [`phase-2.md`](./phase-2.md) | +| 3 | The command and the text say what was selected | [`phase-3.md`](./phase-3.md) | +| 4 | Docs, skill and gates | [`phase-4.md`](./phase-4.md) | + +## Decisions + +| Decision | Why | +| ---------- | ----- | +| A filter matches through `axisValueOf`, exported from `usage-report.ts` | a filter and a row cannot then disagree on a call | +| `--unattributed` is a selector on `task` that also occupies `ticket` | an unattributed call is unattributed on both axes; one place states it | +| Prefixes are resolved in the use case, against the period's calls | the domain filter only ever compares exact values | +| One option parser per flag checks choices and repetition | Commander's `.choices()` replaces any `argParser`, so it cannot also reject a repeated flag | +| The report use case receives `refusedByEnvironment` | `.` runs git, and refusal must come first | diff --git a/aidd_docs/tasks/2026_10/2026_10_15_report-filters/review.md b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/review.md new file mode 100644 index 000000000..13f1084a4 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_report-filters/review.md @@ -0,0 +1,103 @@ +# Review: The report answers about one task, ticket, person or more + +- Rounds: 3 +- Started: 2026-10-15 + +## Round 1 · r-7c3e + +- Date: 2026-10-15 +- By: Baptiste LAFOURCADE +- Diff: `ae8705f7...6651b9f7` +- Axes: code, functional, relevancy +- Verdict: changes-requested +- Score: 10/10 met, 0 unmet, 0 unverified at runtime, 0 out of the diff + +Criteria are the contract's acceptance 1-10, the authority the plan's phase tables fold into. + +### Criteria + +- [x] 1. A filtered report adds up to the row it selects, `day` by its period, `total` by no filter, and every axis of it adds up to its totals — `cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts:207`, green on rerun (`Test Files 42 passed`, `Tests 420 passed`) +- [x] 2. Filters commute and combine with AND — same file `:230`, green +- [x] 3. Bounded and filtered equals unbounded, filtered, restricted to the period, prefixes resolved on the period — `report-window.property.integration.test.ts`, green; the evidence's mutation `resolvedIn(held.records, …)` was killed by it +- [x] 4. Every error of rules 1, 2, 3, 5, 6, 7 exits 1 naming the flag, those of 1, 2, 3, 6, 7 before anything is read — `telemetry-wiring.integration.test.ts` (repeat, empty, choice, conflict tables), `report-usage-use-case.unit.test.ts:464` (gone, outside, unreadable, no id: `s.events` empty); built binary in a sandbox: every case exit 1 with the flag named +- [x] 5. `.` resolves in a linked worktree and the main tree to the id ingest stored — `telemetry-report.e2e.test.ts:541`, green (`Tests 25 passed`); sandbox: main tree, subdirectory, linked worktree and an origin-less repository (root commit `97d2ea86…`) each equal the `--axis repository` key +- [x] 6. No filter: today's envelope plus `filters` all `null` — `report-usage-use-case.unit.test.ts:349`, `report-envelope.unit.test.ts:112`; sandbox keys `version,period,axis,filters,rows,totals,unknown_records,coverage`, every filter `null` +- [x] 7. Tests written first, red run recorded — `evidence.md`, Red runs, phases 1-3, each guard also broken and seen red +- [x] 8. Telemetry mutation at 96 or above, every changed-line mutant killed or explained — `evidence.md` Gates (96.2, floor 96); `cli/reports/mutation/telemetry/mutation.json` holds exactly 10 survivors or uncovered in the four report files: the 5 equivalents on changed lines the evidence explains, and 5 on unchanged lines (`telemetry-report-display.ts:111` x3, `:122`, `:189`) +- [x] 9. Usage contract, `telemetry.md` and the `01-usage` skill say what a filter selects — `aidd_docs/product/usage-contract.md` `### Filters`, `cli/aidd_docs/memory/telemetry.md:60`, `frame.md` step 3, `references/envelope.md` `filters` +- [x] 10. `pnpm smoke` and the tokens end to end, both modes, pass with one filtered report — `evidence.md` Gates, recorded with source unchanged since (only `evidence.md` moved after); `node --test scripts/__tests__/check-claude-tokens-end-to-end.test.js` rerun: `pass 13`, `fail 0` + +### Findings + +- `plugins/aidd-telemetry/skills/01-usage/actions/frame.md:33` : commit `05660554` is typed `docs(telemetry)` yet changes the `01-usage` skill's behaviour (it now frames filtered questions) and adds a display unit test. Merges keep every commit (`RELEASE.md:44`) and no `feat` commit touches `plugins/aidd-telemetry`, so release-please files the skill change under Documentation with no minor bump of `aidd-telemetry` → split it into `feat(aidd-telemetry): …` for `plugins/aidd-telemetry/**`, `test(cli): …` for the display test, `docs(telemetry): …` for the rest; push with `--force-with-lease` — 🟡 major, conform +- `aidd_docs/tasks/2026_10/2026_10_15_report-filters/contract.md:48` : the code refuses a value and `--absent` on the same axis (`--model m --absent model`, exit 1), and `usage-contract.md` and `frame.md` say so; rule 2 of the contract only names the `--unattributed` pairs. The behaviour follows the rule's own reason, but the authority and the docs now disagree → add "a value and `--absent` of the same axis" to rule 2 — 🟢 minor, fit +- `cli/src/contexts/telemetry/application/report/report-usage-use-case.ts:102` : rule 2 is enforced only in the command (`telemetry.ts:271`); the use case given conflicting filters returns an empty report silently. Phase 1 planned a per-axis `ReportFilter` ("No other shape") that made a conflict unrepresentable; the flag-keyed shape traded that for a runtime check one caller remembers → have `execute` refuse when `conflictsOf(asked)` is non-empty, or record the deviation in `plan.md` Decisions — 🟢 minor, architecture +- `cli/src/contexts/telemetry/application/report/report-usage-use-case.ts:118` : `asked.repository as string | null` casts away the `{ here }` branch the line above already ruled out → narrow once, e.g. split `RequestedFilters.repository` into `here` and `typed` before resolving — 🟢 minor, standards +- `cli/src/presentation/commands/telemetry.ts:47` : a repeated bare `--unattributed --unattributed` prints `argument 'null' is invalid. Give it once.` (observed on the built binary) → name the flag without the `null` value, e.g. throw before Commander formats the preset — 🟢 minor, standards +- `cli/src/presentation/display/telemetry/telemetry-report-display.ts:63` : `selectionLine` re-walks the same three selector kinds `selectorsOf` builds in `report-filter.ts:73`, so a new selector kind must be added twice → derive the line from one exported list of `[axis, selector]` pairs — 🟢 minor, rot +- `scripts/check-claude-tokens-end-to-end.cjs:273` : the judge accepts `.` only as a 64-hex id; `repositoryIdOf` returns the 40-hex root commit for a repository with no hosted `origin` (`repository-identity.ts:45`), so a correct `.` there would be called wrong → accept `^[0-9a-f]{40}([0-9a-f]{24})?$`, or compare with the `--axis repository` key — 🟢 minor, code-health +- `plugins/aidd-telemetry/skills/01-usage/actions/answer.md:27` : no step says what `totals.records` 0 with `coverage.records` above 0 means under a filter; only `references/envelope.md` covers it → add the case to step 4, pointing to the reference — 🟢 minor, fit + +### Notes + +- Finding 1, release type: no history rewrite. `next` is squash-merged (`ae8705f7` has one parent), so the PR's title, `feat(telemetry): …`, is the one commit release-please reads, and it decides the release type of every path the branch touches. + +## Round 2 · r-b41d + +- Date: 2026-10-15 +- By: Baptiste LAFOURCADE +- Diff: `ae8705f7...c790dd82` +- Axes: code, functional, relevancy +- Verdict: changes-requested +- Score: 10/10 met, 0 unmet, 0 unverified at runtime, 0 out of the diff + +Criteria are the contract's acceptance 1-10, the authority the plan's phase tables fold into. + +### Criteria + +- [x] 1. A filtered report adds up to the row it selects, `day` by its period, `total` by no filter — `report-filters.property.integration.test.ts:207`, green on rerun (`Test Files 96 passed`, `Tests 1438 passed` over `tests/contexts/telemetry`, the telemetry display, the command wiring and `tests/architecture`) +- [x] 2. Filters commute and combine with AND — same file `:230`, green +- [x] 3. Bounded and filtered equals unbounded and filtered, restricted to the period — `report-window.property.integration.test.ts`, green +- [x] 4. Every error of rules 1, 2, 3, 5, 6, 7 exits 1 naming the flag, those of 1, 2, 3, 6, 7 before anything is read — `report-usage-use-case.unit.test.ts:507` (conflict refused in the use case, before the environment's refusal, locator never asked, `events` empty); built binary in a sandbox: `--unattributed --unattributed` and `--unattributed --unattributed no-binding` → `error: --unattributed is given twice: give it once.` exit 1; `--model m --absent model`, `--absent ticket --unattributed`, and `--task a --unattributed` under `AIDD_TELEMETRY=0` → exit 1 naming both flags; empty, ambiguous and `.` outside a repository unchanged, exit 1 +- [x] 5. `.` resolves to the id ingest stored — `telemetry-report.e2e.test.ts:541` green (`Tests 25 passed`); sandbox `--repository . --json` → the 64-hex id, `totals.records` 4 +- [x] 6. No filter: today's envelope plus `filters` all `null` — `report-usage-use-case.unit.test.ts:349`, `report-envelope.unit.test.ts:112`, green +- [x] 7. Tests written first, red run recorded — `evidence.md` `## Review round 1`, 10 failing before the fix +- [x] 8. Telemetry mutation at 96 or above, changed-line mutants killed or explained — `evidence.md` (`score 96.3, floor 96`); `cli/reports/mutation/telemetry/mutation.json` (22:55) holds 7 survivors or uncovered in the report files: the 2 equivalents the evidence names (`report-usage-use-case.ts:168`, `report-filter.ts:92`) and 5 on unchanged display lines (`:112` x3, `:123`, `:194`) +- [x] 9. Docs say what a filter selects — unchanged from round 1, plus `answer.md:28` for a filter that matched nothing +- [x] 10. Smoke and the tokens end to end pass — smoke rerun on the `22:57` build, relocated HOME: `PASS: 132 FAIL: 0 SKIP: 1`; `node --test scripts/__tests__/check-claude-tokens-end-to-end.test.js`: `pass 14`, `fail 0`, the root-commit case included; both live modes recorded in `evidence.md` on this head + +### Findings + +- `plugins/aidd-telemetry/skills/01-usage/actions/frame.md:33` : round 1's major is not fixed. Commit `05660554` is still `docs(telemetry)` and still carries the skill's new framing and a display test; no commit was split or retyped. The only other commit touching `plugins/aidd-telemetry` is `c790dd82` (`fix`), so `aidd-telemetry` releases a patch, with the feature under Documentation and Bug Fixes → split `05660554` into `feat(aidd-telemetry): …` for `plugins/aidd-telemetry/**`, `test(cli): …` for the display test and `docs(telemetry): …` for the rest, and push with `--force-with-lease` — 🟡 major, conform +- `cli/src/presentation/commands/telemetry.ts:281` : the command still runs `conflictsOf` itself before `createDeps`, so the use case's own refusal never runs from the CLI, and the refusal is now worded through two call sites → drop the command's check and let the `conflicting` result exit 1, which the action already handles at `:299`; or keep it and accept the duplicate — 🟢 minor, rot +- `cli/src/contexts/telemetry/application/report/report-usage-use-case.ts:29` : `RequestedFilters` lets a caller pass `here` and a non-null `repository` together; `here` then wins and `repository` is silently dropped → make the two exclusive in the type, or refuse the pair as a conflict — 🟢 minor, standards + +## Round 3 · r-e95a + +- Date: 2026-10-15 +- By: Baptiste LAFOURCADE +- Diff: `ae8705f7...20661e65` +- Axes: code, functional, relevancy +- Verdict: approve +- Score: 10/10 met, 0 unmet, 0 unverified at runtime, 0 out of the diff + +Criteria are the contract's acceptance 1-10. Only the command and the use case's request type changed since round 2, so 1-3 and 5-10 stand on round 2's evidence, rerun where the change reaches. + +### Criteria + +- [x] 1. A filtered report adds up to the row it selects — `report-filters.property.integration.test.ts:207`, green on rerun (`Test Files 42 passed`, `Tests 424 passed` over the report domain and application, the telemetry display, the command wiring and `tests/architecture`) +- [x] 2. Filters commute and combine with AND — same file `:230`, green +- [x] 3. Bounded and filtered equals unbounded and filtered, restricted to the period — `report-window.property.integration.test.ts`, green +- [x] 4. Every error exits 1 naming the flag, conflicts before anything is read — the command no longer checks conflicts (`telemetry.ts`, `conflictsOf` gone), the use case refuses them first (`report-usage-use-case.unit.test.ts`); built binary in a sandbox: `--model m --absent model`, `--absent ticket --unattributed`, `--ticket T --unattributed no-binding`, `--unattributed --task a --json`, `--repository . --task a --unattributed`, and `--task a --unattributed` under `AIDD_TELEMETRY=0` each exit 1 naming both flags, and the ledger holds no day partition after them +- [x] 5. `.` resolves to the id ingest stored — `telemetry-report.e2e.test.ts:541` green (`Tests 25 passed`); sandbox `--task auth --repository . --axis day` → `Selected: task = auth, repository = f60ecdb1`, 11 + 22 = 33 +- [x] 6. No filter: today's envelope plus `filters` all `null` — `report-usage-use-case.unit.test.ts:349`, `report-envelope.unit.test.ts:112`, green +- [x] 7. Tests written first, red run recorded — `evidence.md` +- [x] 8. Telemetry mutation at 96 or above — `evidence.md` (`score 96.3, floor 96`), recorded on `c790dd82`; this round removes a duplicate check and narrows a type, adding no branch +- [x] 9. Docs say what a filter selects — unchanged since round 2 +- [x] 10. Smoke and the tokens end to end pass — round 2's smoke rerun and `pass 14` stand; typecheck, `biome check` and `knip` clean on this head + +### Findings + +- Round 2's `telemetry.ts:281` duplicate conflict check: fixed, the use case is the one refusal. +- Round 2's `report-usage-use-case.ts:29` `here` with `repository`: fixed, `RequestedFilters` is a union in which `here` forces `repository: null`. +- Round 2's commit-type major on `05660554`: accepted by decision, not fixed. Every PR into `next` is squash-merged; the last 6 commits on `origin/next` each have one parent and carry their PR number (`fcb6022b … (#1032)`, `0a103e67 … (#1031)`, `aaca070a … (#993)`), and this PR will merge as `feat(telemetry)`, the only commit release-please reads. The branch's commit types then never reach a release. diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index f6b86a096..f6601174d 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -22,7 +22,7 @@ flowchart LR 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"] + Ledger --> Report["report: attribute, keep what the filters name, then split by one axis"] Bindings --> Report Report --> Out["text or the version 1 envelope"] ``` @@ -57,6 +57,7 @@ flowchart LR | How the ledger lies on disk, and what an ingest or a report reads | `infrastructure/usage-ledger-adapter.ts`, `domain/stored-usage.ts`, `application/ledger-upkeep.ts`, `domain/report/period.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` | +| What a report's `filters` select (`--task`, `--ticket`, `--person`, `--model`, `--agent`, `--session`, `--repository`, `--absent`, `--unattributed`), and where a start of an id or `.` is resolved | `domain/report/report-filter.ts`, `application/report/report-usage-use-case.ts` | ## The plugin side diff --git a/cli/src/contexts/telemetry/application/report/report-envelope.ts b/cli/src/contexts/telemetry/application/report/report-envelope.ts index b94d82ddc..fc7e6ce5c 100644 --- a/cli/src/contexts/telemetry/application/report/report-envelope.ts +++ b/cli/src/contexts/telemetry/application/report/report-envelope.ts @@ -1,5 +1,6 @@ import type { UnattributedReason } from "../../domain/report/attribution.js"; import type { Period } from "../../domain/report/period.js"; +import type { ReportFilters } from "../../domain/report/report-filter.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"; @@ -43,6 +44,11 @@ export interface EnvelopeRow extends EnvelopeTally { * and the day on each side, so damage outside them is not counted. Nothing here is a path, a * working directory or a branch name. * + * `filters` names what the report was narrowed to, every flag in a fixed order, `null` when it + * was not given, else as resolved: the full id for a start or `.`, the start as typed when it + * names no call of the period. `rows` and `totals` count the calls that match; + * `coverage.records` counts every call of the period, matching or not. + * * Under `AIDD_TELEMETRY=0` nothing is read and the envelope is `{ "version": 1, "refused": * "AIDD_TELEMETRY=0" }` (`REFUSED_ENVELOPE`). * @@ -52,6 +58,7 @@ export interface ReportEnvelope { readonly version: 1; readonly period: Period; readonly axis: ReportAxis; + readonly filters: ReportFilters; readonly rows: readonly EnvelopeRow[]; readonly totals: EnvelopeTally; readonly unknown_records: number; @@ -94,11 +101,22 @@ function rowOf(row: ReportRow): EnvelopeRow { export function reportEnvelopeOf( result: Extract ): ReportEnvelope { - const { period, report, coverage } = result; + const { period, filters, report, coverage } = result; return { version: 1, period, axis: report.axis, + filters: { + task: filters.task, + ticket: filters.ticket, + person: filters.person, + model: filters.model, + agent: filters.agent, + session: filters.session, + repository: filters.repository, + absent: filters.absent, + unattributed: filters.unattributed, + }, rows: report.rows.map(rowOf), totals: tallyOf(report.totals), unknown_records: report.totals.total.unknownRecords, 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 index f79e45a8d..3b5138de4 100644 --- a/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts +++ b/cli/src/contexts/telemetry/application/report/report-usage-use-case.ts @@ -1,21 +1,46 @@ import type { PersonIdentityStore } from "../../domain/ports/identity/person-identity-store.js"; +import type { RepositoryLocator } from "../../domain/ports/repository-locator.js"; import type { UsageLedger } from "../../domain/ports/usage-ledger.js"; +import { attribute } from "../../domain/report/attribution.js"; import { inPeriod, type Period, windowOf } from "../../domain/report/period.js"; +import { + type AbsentAxis, + conflictsOf, + type FilterFlag, + matchesFilter, + NO_FILTERS, + type ReportFilters, + resolveId, +} from "../../domain/report/report-filter.js"; import { buildReport, type ReportAxis, type UsageReport, } from "../../domain/report/usage-report.js"; +import { repositoryIdOf } from "../../domain/repository-identity.js"; import type { NotStoredReason } from "../../domain/repository-resolution.js"; +import type { StoredUsage } from "../../domain/stored-usage.js"; import type { IngestUsageUseCase } from "../ingest-usage-use-case.js"; import { settleLedger } from "../ledger-upkeep.js"; import type { DeclaredBindings } from "./declared-bindings.js"; +/** The filters as typed: a session or repository may be the start of an id. A repository is + * either typed, or `--repository .`, the directory `here` whose repository is asked for; never + * both. */ +export type RequestedFilters = + | (ReportFilters & { readonly here?: undefined }) + | (ReportFilters & { readonly repository: null; readonly here: string }); + export interface ReportRequest { readonly axis: ReportAxis; readonly period: Period; + /** None given: every call of the period. */ + readonly filters?: RequestedFilters; } +/** The flags typed as the start of an id. */ +type PrefixFlag = "session" | "repository"; + /** 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. */ @@ -37,9 +62,26 @@ export interface ReportCoverage { export type ReportResult = | { readonly status: "refused" } + /** Two flags given select on one axis, so together they would keep nothing. */ + | { + readonly status: "conflicting"; + readonly flags: readonly [FilterFlag, FilterFlag]; + readonly absent: AbsentAxis | null; + } + /** `.` was asked for in a directory that names no repository. */ + | { readonly status: "not-a-repository" } + /** A start two ids or more of the period share, all of them, sorted. */ + | { + readonly status: "ambiguous"; + readonly flag: PrefixFlag; + readonly typed: string; + readonly ids: readonly string[]; + } | { readonly status: "reported"; readonly period: Period; + /** As resolved: a full id for a start or `.`, the start as typed when it names nothing. */ + readonly filters: ReportFilters; readonly report: UsageReport; readonly coverage: ReportCoverage; }; @@ -50,32 +92,53 @@ export type ReportResult = * read: one call in one partition, by day, whatever order the ingests ran in. A report with a * bounded period reads and settles only the partitions that can hold a call of it (see * `windowOf`), so its cost follows its period and not the history; one with no bound reads and - * settles the whole ledger. */ + * settles the whole ledger. + * + * Filters keep the calls of the period whose axes are what they name, after attribution, so + * the report they give adds up to the row of the unfiltered one they select. Flags that select + * on one axis are refused first, then the environment's refusal, then `.` is located, before + * anything is read: refusal means no git is run either. */ export class ReportUsageUseCase { constructor( private readonly ingest: IngestUsageUseCase, private readonly ledger: UsageLedger, private readonly declared: DeclaredBindings, - private readonly identity: PersonIdentityStore + private readonly identity: PersonIdentityStore, + private readonly locator: RepositoryLocator, + private readonly options: { readonly refusedByEnvironment: boolean } ) {} async execute(request: ReportRequest): Promise { + const { here, ...asked }: RequestedFilters = request.filters ?? NO_FILTERS; + const [conflict] = conflictsOf(asked); + if (conflict !== undefined) { + return { status: "conflicting", flags: conflict, absent: asked.absent }; + } + if (this.options.refusedByEnvironment) return { status: "refused" }; + const located = here === undefined ? undefined : await this.repositoryIn(here); + if (located === null) return { status: "not-a-repository" }; const ingested = await this.ingest.execute(); - if (ingested.refused) return { status: "refused" }; const held = await this.ledger.exclusively(() => settleLedger(this.ledger, windowOf(request.period)) ); 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 + const session = resolvedIn(records, "session", asked.session); + if ("status" in session) return session; + // `.` names one repository in full: it is never the start of another. + const repository = + located === undefined ? resolvedIn(records, "repository", asked.repository) : { id: located }; + if ("status" in repository) return repository; + const filters: ReportFilters = { ...asked, session: session.id, repository: repository.id }; + const facts = await this.declared.read(); + const person = await this.identity.read(); + const selected = records.filter((record) => + matchesFilter(filters, record, attribute(record, facts), person) ); return { status: "reported", period: request.period, - report, + filters, + report: buildReport(selected, facts, person, request.axis), coverage: { filesRead: ingested.filesRead, records: records.length, @@ -87,4 +150,26 @@ export class ReportUsageUseCase { }, }; } + + /** The repository a directory is in, named as ingest names it; `null` when it names none. */ + private async repositoryIn(dir: string): Promise { + const located = await this.locator.locate(dir); + return located.status === "repository" ? repositoryIdOf(located) : null; + } +} + +/** A session or repository typed in full or by its start, among the ids of the period's calls. + * A start that names none is kept as typed, and keeps no call. */ +function resolvedIn( + records: readonly StoredUsage[], + flag: PrefixFlag, + typed: string | null +): { readonly id: string | null } | Extract { + if (typed === null) return { id: null }; + const found = resolveId( + typed, + records.map((record) => (flag === "session" ? record.session_id : record.repository_id)) + ); + if (found.kind === "ambiguous") return { status: "ambiguous", flag, typed, ids: found.ids }; + return { id: found.kind === "found" ? found.id : typed }; } diff --git a/cli/src/contexts/telemetry/domain/declaration/task-declaration.ts b/cli/src/contexts/telemetry/domain/declaration/task-declaration.ts index 4091eed4e..c83922482 100644 --- a/cli/src/contexts/telemetry/domain/declaration/task-declaration.ts +++ b/cli/src/contexts/telemetry/domain/declaration/task-declaration.ts @@ -42,7 +42,9 @@ export type DeclarationRequest = | { readonly kind: "task"; readonly task: string; readonly ticket: string | null } | { readonly kind: "none" }; -function textOrNull(value: string | null | undefined): string | null { +/** Text as a person typed it, trimmed; `null` when nothing is left. A report filter is trimmed + * the same way, so it compares with what was stored. */ +export function textOrNull(value: string | null | undefined): string | null { const trimmed = value?.trim() ?? ""; return trimmed === "" ? null : trimmed; } diff --git a/cli/src/contexts/telemetry/domain/report/report-filter.ts b/cli/src/contexts/telemetry/domain/report/report-filter.ts new file mode 100644 index 000000000..7f8529042 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/report-filter.ts @@ -0,0 +1,128 @@ +import type { StoredUsage } from "../stored-usage.js"; +import { compareText } from "../text-order.js"; +import { type Attribution, UNATTRIBUTED_REASONS } from "./attribution.js"; +import { type AxisValue, axisValueOf, type ReportAxis } from "./usage-report.js"; + +/** The axes a call can have no value for: no model said, no identity set, a task declared + * without a ticket. `session`, `agent` and `repository` always have one, and work with no task + * is unattributed, not absent. */ +export const ABSENT_AXES = ["person", "model", "ticket"] as const; +export type AbsentAxis = (typeof ABSENT_AXES)[number]; + +/** The axes a flag names a value of. `day` is selected by the period, `total` by no filter. */ +export const FILTER_AXES = [ + "task", + "ticket", + "person", + "model", + "agent", + "session", + "repository", +] as const; +export type FilterAxis = (typeof FILTER_AXES)[number]; + +/** `--unattributed`: one reason, or either. */ +export const UNATTRIBUTED_SELECTORS = [...UNATTRIBUTED_REASONS, "any"] as const; +export type UnattributedSelector = (typeof UNATTRIBUTED_SELECTORS)[number]; + +/** What a report is narrowed to, one key per flag, in the order the envelope prints them; + * `null` when the flag is not given. A call is kept when every flag given selects it. */ +export interface ReportFilters extends Readonly> { + readonly absent: AbsentAxis | null; + readonly unattributed: UnattributedSelector | null; +} +export type FilterFlag = keyof ReportFilters; + +export const NO_FILTERS: ReportFilters = { + task: null, + ticket: null, + person: null, + model: null, + agent: null, + session: null, + repository: null, + absent: null, + unattributed: null, +}; + +const FLAGS = Object.keys(NO_FILTERS) as FilterFlag[]; + +/** What a flag asks an axis to be: a row's value, or work with no task for either reason. */ +export type Selector = AxisValue | { readonly kind: "unattributed-any" }; + +/** The axes a flag selects on. Work with no task has neither task nor ticket, so + * `--unattributed` selects on both. */ +function axesOf(filters: ReportFilters, flag: FilterFlag): readonly ReportAxis[] { + if (filters[flag] === null) return []; + if (flag === "absent") return [filters[flag] as AbsentAxis]; + return flag === "unattributed" ? ["task", "ticket"] : [flag]; +} + +/** Pairs of flags given that select on the same axis, in the order the flags are printed: + * two selectors on one axis keep nothing, so they are refused rather than answered. */ +export function conflictsOf(filters: ReportFilters): (readonly [FilterFlag, FilterFlag])[] { + return FLAGS.flatMap((flag, index) => + FLAGS.slice(index + 1) + .filter((other) => + axesOf(filters, flag).some((axis) => axesOf(filters, other).includes(axis)) + ) + .map((other) => [flag, other] as const) + ); +} + +/** What each flag given asks the call's axis to be, as a row of that axis holds it, in the + * order the flags are printed. An unattributed call is unattributed on task and ticket alike, + * so its task alone is asked. */ +export function selectorsOf(filters: ReportFilters): (readonly [ReportAxis, Selector])[] { + const selectors: (readonly [ReportAxis, Selector])[] = FILTER_AXES.flatMap((axis) => { + const value = filters[axis]; + return value === null ? [] : [[axis, { kind: "value", value }] as const]; + }); + if (filters.absent !== null) selectors.push([filters.absent, { kind: "absent" }]); + if (filters.unattributed === "any") selectors.push(["task", { kind: "unattributed-any" }]); + else if (filters.unattributed !== null) { + selectors.push(["task", { kind: "unattributed", reason: filters.unattributed }]); + } + return selectors; +} + +function selects(selector: Selector, value: AxisValue): boolean { + switch (selector.kind) { + case "value": + return value.kind === "value" && value.value === selector.value; + case "absent": + return value.kind === "absent"; + case "unattributed": + return value.kind === "unattributed" && value.reason === selector.reason; + case "unattributed-any": + return value.kind === "unattributed"; + } +} + +/** Whether every flag given keeps the call: its axis is, by the function that builds the rows, + * exactly what the flag names. A filter and a row cannot then disagree on a call. */ +export function matchesFilter( + filters: ReportFilters, + record: StoredUsage, + attribution: Attribution, + person: string | null +): boolean { + return selectorsOf(filters).every(([axis, selector]) => + selects(selector, axisValueOf(axis, record, attribution, person)) + ); +} + +export type ResolvedId = + | { readonly kind: "found"; readonly id: string } + | { readonly kind: "ambiguous"; readonly ids: readonly string[] } + | { readonly kind: "none" }; + +/** An id typed in full or by its start, among the values an axis has: the value equal to it, + * else the one value it starts. */ +export function resolveId(typed: string, values: Iterable): ResolvedId { + const known = new Set(values); + if (known.has(typed)) return { kind: "found", id: typed }; + const ids = [...known].filter((id) => id.startsWith(typed)).sort(compareText); + if (ids.length === 0) return { kind: "none" }; + return ids.length === 1 ? { kind: "found", id: ids[0] as string } : { kind: "ambiguous", ids }; +} diff --git a/cli/src/contexts/telemetry/domain/report/usage-report.ts b/cli/src/contexts/telemetry/domain/report/usage-report.ts index 01c34fa06..288ea03b0 100644 --- a/cli/src/contexts/telemetry/domain/report/usage-report.ts +++ b/cli/src/contexts/telemetry/domain/report/usage-report.ts @@ -91,7 +91,8 @@ function unattributedValue( return { kind: "unattributed", reason: attribution.reason }; } -function axisValueOf( +/** What a call is on one axis: the row it lands on, and what a filter on that axis compares. */ +export function axisValueOf( axis: ReportAxis, record: StoredUsage, attribution: Attribution, diff --git a/cli/src/presentation/commands/telemetry.ts b/cli/src/presentation/commands/telemetry.ts index d20b97f5b..c1fbcde70 100644 --- a/cli/src/presentation/commands/telemetry.ts +++ b/cli/src/presentation/commands/telemetry.ts @@ -1,18 +1,26 @@ -import { type Command, Option } from "commander"; +import { type Command, InvalidArgumentError, Option } from "commander"; import { REFUSED_ENVELOPE, reportEnvelopeOf, } from "../../contexts/telemetry/application/report/report-envelope.js"; +import type { RequestedFilters } from "../../contexts/telemetry/application/report/report-usage-use-case.js"; import { DECLARED_BY, type DeclaredBy, requestOf, + textOrNull, } from "../../contexts/telemetry/domain/declaration/task-declaration.js"; import { periodOf } from "../../contexts/telemetry/domain/report/period.js"; +import { + ABSENT_AXES, + type ReportFilters, + UNATTRIBUTED_SELECTORS, +} from "../../contexts/telemetry/domain/report/report-filter.js"; import { REPORT_AXES, type ReportAxis, } from "../../contexts/telemetry/domain/report/usage-report.js"; +import { USAGE_AGENTS } from "../../contexts/telemetry/domain/usage-record.js"; import { createDeps } from "../../runtime/wiring/framework.js"; import { printForgetResult, @@ -29,6 +37,32 @@ import { import { ErrorHandler } from "../error-handler.js"; import { parseGlobalOptions } from "./global-options.js"; +/** One value per flag, trimmed, and one of `choices` when it has them. Commander keeps the last + * of a repeated flag silently, and its own `choices` replaces any parser, so both are checked + * here. A repeat is refused by the flag's name: a bare flag's value is its preset, not what was + * typed. */ +function filterOption( + command: Command, + flags: string, + description: string, + choices?: readonly string[] +): Option { + const option = new Option(flags, description); + return (choices === undefined ? option : option.choices(choices)).argParser( + (value: string, previous: string | undefined): string => { + if (previous !== undefined) { + command.error(`error: ${option.long} is given twice: give it once.`); + } + const text = textOrNull(value); + if (text === null) throw new InvalidArgumentError("It is empty."); + if (choices !== undefined && !choices.includes(text)) { + throw new InvalidArgumentError(`Allowed choices are ${choices.join(", ")}.`); + } + return text; + } + ); +} + export function registerTelemetryCommand(program: Command): void { const telemetry = program .command("telemetry") @@ -172,8 +206,8 @@ export function registerTelemetryCommand(program: Command): void { } }); - telemetry - .command("report") + const report = telemetry.command("report"); + report .description("Show what the work consumed, split along one axis, after reading new transcripts") .option("--from ", "First day to include, as YYYY-MM-DD (UTC)") .option("--to ", "Last day to include, as YYYY-MM-DD (UTC)") @@ -181,27 +215,84 @@ export function registerTelemetryCommand(program: Command): void { .addOption( new Option("--axis ", "What to split by").choices(REPORT_AXES).default("total") ) + .addOption(filterOption(report, "--task ", "Only the calls of this task")) + .addOption(filterOption(report, "--ticket ", "Only the calls of this ticket, as declared")) + .addOption(filterOption(report, "--person ", "Only the calls of this identity")) + .addOption(filterOption(report, "--model ", "Only the calls of this model")) + .addOption( + filterOption(report, "--agent ", "Only the calls of this agent", USAGE_AGENTS) + ) + .addOption( + filterOption(report, "--session ", "Only the calls of this session, or a start of its id") + ) + .addOption( + filterOption( + report, + "--repository ", + "Only the calls of this repository, a start of its id, or . for the one here" + ) + ) + .addOption( + filterOption( + report, + "--absent ", + "Only the calls with no value on this axis", + ABSENT_AXES + ) + ) + .addOption( + filterOption( + report, + "--unattributed [reason]", + "Only the work with no task, for this reason", + UNATTRIBUTED_SELECTORS + ).preset("any") + ) .option("--json", "Print a versioned JSON envelope instead of text", false) .action( - async (cmdOptions: { - from?: string; - to?: string; - days?: string; - axis: ReportAxis; - json: boolean; - }) => { + async ( + cmdOptions: { + from?: string; + to?: string; + days?: string; + axis: ReportAxis; + json: boolean; + } & Partial + ) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const period = periodOf(cmdOptions, new Date()); if (!period.ok) { output.error(period.message); process.exit(1); } + const given: ReportFilters = { + task: cmdOptions.task ?? null, + ticket: cmdOptions.ticket ?? null, + person: cmdOptions.person ?? null, + model: cmdOptions.model ?? null, + agent: cmdOptions.agent ?? null, + session: cmdOptions.session ?? null, + repository: cmdOptions.repository ?? null, + absent: cmdOptions.absent ?? null, + unattributed: cmdOptions.unattributed ?? null, + }; + const filters: RequestedFilters = + given.repository === "." ? { ...given, repository: null, here: projectRoot } : given; try { const deps = await createDeps(projectRoot, { verbose }, output); const result = await deps.telemetry.reportUsageUseCase.execute({ axis: cmdOptions.axis, period: period.period, + filters, }); + if ( + result.status === "conflicting" || + result.status === "not-a-repository" || + result.status === "ambiguous" + ) { + printUsageReport(output, result); + process.exit(1); + } if (!cmdOptions.json) return printUsageReport(output, result); output.print( JSON.stringify( diff --git a/cli/src/presentation/display/telemetry/telemetry-report-display.ts b/cli/src/presentation/display/telemetry/telemetry-report-display.ts index d4d37049b..151c9b4b2 100644 --- a/cli/src/presentation/display/telemetry/telemetry-report-display.ts +++ b/cli/src/presentation/display/telemetry/telemetry-report-display.ts @@ -3,6 +3,10 @@ import type { ReportResult, } from "../../../contexts/telemetry/application/report/report-usage-use-case.js"; import type { Period } from "../../../contexts/telemetry/domain/report/period.js"; +import { + type ReportFilters, + selectorsOf, +} from "../../../contexts/telemetry/domain/report/report-filter.js"; import { type AxisValue, COUNTERS, @@ -55,6 +59,23 @@ function labelOf(axis: ReportAxis, value: AxisValue): string { return oneLine(shown); } +/** Each filter given, as the row it selects is labelled; `null` when none is. */ +function selectionLine(filters: ReportFilters): string | null { + const parts = selectorsOf(filters).map( + ([axis, selector]) => + `${axis} = ${selector.kind === "unattributed-any" ? "unattributed, either reason" : labelOf(axis, selector)}` + ); + return parts.length === 0 ? null : `Selected: ${parts.join(", ")}`; +} + +/** Two flags given that select on one axis, as they were typed. */ +function conflictWords(result: Extract): string { + const [first, second] = result.flags.map((flag) => + flag === "absent" ? `--absent ${result.absent}` : `--${flag}` + ); + return `${first} and ${second} select on the same axis, so together they keep nothing: give one of them.`; +} + function grouped(n: number): string { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); } @@ -137,10 +158,34 @@ export function printUsageReport(output: CLIOutput, result: ReportResult): void output.info("AIDD_TELEMETRY=0: nothing was read and nothing is reported."); return; } - const { report, coverage, period } = result; + if (result.status === "conflicting") { + output.error(conflictWords(result)); + return; + } + if (result.status === "not-a-repository") { + output.error( + "--repository . names the repository this directory is in, and it is in none: not a git repository, unreadable, or with neither an origin nor a commit." + ); + return; + } + if (result.status === "ambiguous") { + const { flag, typed, ids } = result; + const many = flag === "session" ? "sessions" : "repositories"; + output.error( + `--${flag} ${typed} starts ${ids.length} ${many} of this period: ${ids.join(", ")}. Type more of it.` + ); + return; + } + const { report, coverage, period, filters } = result; output.print(`Usage, ${periodWords(period)}, by ${report.axis}`); + const selection = selectionLine(filters); + if (selection !== null) output.print(selection); if (report.totals.records === 0) { - output.print("No calls in this period."); + output.print( + selection === null + ? "No calls in this period." + : "No call in this period matches these filters." + ); } else { output.print(""); for (const line of table(report.axis, report.rows, report.totals)) output.print(line); diff --git a/cli/src/runtime/wiring/telemetry.ts b/cli/src/runtime/wiring/telemetry.ts index 02ab6b5ee..2ddb0bc8a 100644 --- a/cli/src/runtime/wiring/telemetry.ts +++ b/cli/src/runtime/wiring/telemetry.ts @@ -151,7 +151,9 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { ingestUsageUseCase, ledger, new DeclaredBindings(sessions, snapshotStore), - identity + identity, + locator, + { refusedByEnvironment } ), manageIdentityUseCase: new ManageIdentityUseCase(identity), telemetryOnUseCase: new TelemetryOnUseCase( diff --git a/cli/tests/architecture/context-boundary.arch.test.ts b/cli/tests/architecture/context-boundary.arch.test.ts index a2c3d30ab..652db3161 100644 --- a/cli/tests/architecture/context-boundary.arch.test.ts +++ b/cli/tests/architecture/context-boundary.arch.test.ts @@ -129,12 +129,14 @@ const PUBLIC_MODULES: Readonly> = { "src/contexts/telemetry/application/declare-task-use-case.ts", "src/contexts/telemetry/application/telemetry-status-use-case.ts", "src/contexts/telemetry/application/consented-repositories.ts", - // the report a command asks for, the days it is asked over, the axes it splits by, and - // the JSON envelope it prints; the display words the same result + // the report a command asks for, the days it is asked over, the axes it splits by, the + // filters it is narrowed to, and the JSON envelope it prints; the display words the same + // result "src/contexts/telemetry/application/report/report-usage-use-case.ts", "src/contexts/telemetry/application/report/report-envelope.ts", "src/contexts/telemetry/domain/report/period.ts", "src/contexts/telemetry/domain/report/usage-report.ts", + "src/contexts/telemetry/domain/report/report-filter.ts", // who, if anyone, the measurement names, and the result the command prints "src/contexts/telemetry/application/identity/manage-identity-use-case.ts", // opting in, out and forgetting: the use cases a command drives and the results it prints, diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index ae467fc4c..8e7278c35 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -103,6 +103,10 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] // what its clone says now, what was remembered of the directory, and when the clone // consented. The fifth is the machine's spelling of a directory and the clock. { path: "src/contexts/telemetry/application/directory-resolver.ts", injected: 5 }, + // A report reads what is new, settles the ledger, and attributes by what was declared and + // who is named. The fifth locates `.` for `--repository .`, and the sixth is the + // environment's refusal: it must come before `.` runs git, so it cannot be left to ingest. + { path: "src/contexts/telemetry/application/report/report-usage-use-case.ts", injected: 6 }, // A declaration is checked against the clone's consent, kept for the session under the // bindings lock, and kept for the branch, whose snapshot takes that lock too and shares it // with ingest. The fifth is the environment's refusal. The lock cannot sit inside a store: it diff --git a/cli/tests/contexts/telemetry/application/report/report-envelope.unit.test.ts b/cli/tests/contexts/telemetry/application/report/report-envelope.unit.test.ts index 40f0f5f83..9df3f9d10 100644 --- a/cli/tests/contexts/telemetry/application/report/report-envelope.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-envelope.unit.test.ts @@ -19,6 +19,18 @@ const tally = (records: number, unknown: number): Tally => ({ const reported: Extract = { status: "reported", period: { from: "2026-10-01", to: null }, + // Given out of order: the envelope prints its own order, whatever the object's. + filters: { + unattributed: null, + absent: "model", + repository: "r".repeat(64), + session: null, + agent: "subagent", + model: null, + person: null, + ticket: null, + task: "checkout", + }, report: { axis: "task", rows: [ @@ -97,6 +109,22 @@ describe("the JSON envelope of a report", () => { }); }); + it("names what the report was narrowed to, every flag in a fixed order, as resolved", () => { + const envelope = reportEnvelopeOf(reported); + expect(Object.keys(envelope).slice(0, 4)).toEqual(["version", "period", "axis", "filters"]); + expect(Object.entries(envelope.filters)).toEqual([ + ["task", "checkout"], + ["ticket", null], + ["person", null], + ["model", null], + ["agent", "subagent"], + ["session", null], + ["repository", "r".repeat(64)], + ["absent", "model"], + ["unattributed", null], + ]); + }); + it("is versioned when nothing was read under AIDD_TELEMETRY=0", () => { expect(REFUSED_ENVELOPE).toEqual({ version: 1, refused: "AIDD_TELEMETRY=0" }); }); diff --git a/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts new file mode 100644 index 000000000..bf766ab9b --- /dev/null +++ b/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts @@ -0,0 +1,245 @@ +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { DirectoryResolver } from "../../../../../src/contexts/telemetry/application/directory-resolver.js"; +import { IngestUsageUseCase } from "../../../../../src/contexts/telemetry/application/ingest-usage-use-case.js"; +import { DeclaredBindings } from "../../../../../src/contexts/telemetry/application/report/declared-bindings.js"; +import { + type EnvelopeRow, + type EnvelopeTally, + type ReportEnvelope, + reportEnvelopeOf, +} from "../../../../../src/contexts/telemetry/application/report/report-envelope.js"; +import { + type ReportRequest, + ReportUsageUseCase, +} from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; +import { SnapshotBindingsUseCase } from "../../../../../src/contexts/telemetry/application/snapshot-bindings-use-case.js"; +import { declarationOf } from "../../../../../src/contexts/telemetry/domain/declaration/task-declaration.js"; +import type { Period } from "../../../../../src/contexts/telemetry/domain/report/period.js"; +import { + conflictsOf, + NO_FILTERS, + type ReportFilters, +} from "../../../../../src/contexts/telemetry/domain/report/report-filter.js"; +import { + REPORT_AXES, + type ReportAxis, +} from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; +import type { StoredUsage } from "../../../../../src/contexts/telemetry/domain/stored-usage.js"; +import { USAGE_AGENTS } from "../../../../../src/contexts/telemetry/domain/usage-record.js"; +import { + FakeBindings, + FakeConsents, + FakeLocator, + InMemoryBindingsLock, + InMemoryConsentHistory, + InMemoryIdentity, + InMemoryLedger, + InMemoryResolutions, + InMemorySessions, + InMemorySnapshots, +} from "../../../../helpers/ports/in-memory-telemetry.js"; + +/** Two sessions share a start, so a prefix can name one, the other, or both. */ +const SESSIONS = [ + "a1b2c3d4-0000-4000-8000-000000000001", + "a1b2ffff-0000-4000-8000-000000000002", + "b0000000-0000-4000-8000-000000000003", +]; +const REPOSITORIES = ["r-a1", "r-a2", "r-b"]; +const DAYS = ["2026-10-01", "2026-10-02", "2026-10-03"]; +const TASKS = ["t-1", "t-2", null]; +const TICKETS = ["T-1", null]; + +const call = fc.record({ + session: fc.constantFrom(...SESSIONS), + repository: fc.constantFrom(...REPOSITORIES), + agent: fc.constantFrom(...USAGE_AGENTS), + model: fc.constantFrom("m-1", "m-2", null), + day: fc.constantFrom(...DAYS), + hour: fc.integer({ min: 0, max: 23 }), + input: fc.option(fc.nat(9), { nil: null, freq: 4 }), +}); +const declaration = fc.record({ + session: fc.constantFrom(...SESSIONS), + task: fc.constantFrom(...TASKS), + ticket: fc.constantFrom(...TICKETS), + day: fc.constantFrom("2026-09-30", ...DAYS), +}); +const edge = fc.option(fc.constantFrom(...DAYS), { nil: null }); +const period = fc + .tuple(edge, edge) + .map( + ([a, b]): Period => + a !== null && b !== null && a > b ? { from: b, to: a } : { from: a, to: b } + ); + +const ledgerOf = fc.record({ + calls: fc.array(call, { maxLength: 12 }), + declarations: fc.array(declaration, { maxLength: 4 }), + person: fc.constantFrom("p-1", null), +}); +type Ledger = typeof ledgerOf extends fc.Arbitrary ? T : never; + +/** A report over a ledger laid out as given. Each call's output is its own power of two, so + * the output a report sums names the very calls it kept. */ +async function reporterOf({ calls, declarations, person }: Ledger) { + const ledger = new InMemoryLedger(); + ledger.records = calls.map( + (c, index): StoredUsage => ({ + tool: "claude-code", + tool_version: null, + key: `k${index}`, + session_id: c.session, + agent: c.agent, + at: `${c.day}T${String(c.hour).padStart(2, "0")}:00:00.000Z`, + model: c.model, + input: c.input, + output: 2 ** index, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: null, + git_branch: null, + repository_id: c.repository, + }) + ); + const sessions = new InMemorySessions(); + for (const d of declarations) { + await sessions.append( + d.session, + declarationOf( + d.task === null ? { kind: "none" } : { kind: "task", task: d.task, ticket: d.ticket }, + new Date(`${d.day}T12:00:00.000Z`), + "command" + ) + ); + } + const identity = new InMemoryIdentity(); + identity.personId = person; + const now = () => new Date("2026-10-09T00:00:00.000Z"); + const snapshots = new InMemorySnapshots(); + const use = new ReportUsageUseCase( + new IngestUsageUseCase( + [], + ledger, + new DirectoryResolver( + new FakeLocator(), + new FakeConsents(), + new InMemoryResolutions(), + new InMemoryConsentHistory(), + { caseInsensitiveFileSystem: false, now } + ), + new SnapshotBindingsUseCase(new FakeBindings(), snapshots, new InMemoryBindingsLock(), now), + { refusedByEnvironment: false } + ), + ledger, + new DeclaredBindings(sessions, snapshots), + identity, + new FakeLocator(), + { refusedByEnvironment: false } + ); + return async (request: ReportRequest): Promise => { + const result = await use.execute(request); + if (result.status !== "reported") throw new Error(`not reported: ${result.status}`); + return reportEnvelopeOf(result); + }; +} + +function tallyOf(row: EnvelopeRow): EnvelopeTally { + const { kind: _kind, key: _key, reason: _reason, ...tally } = row; + return tally; +} + +/** The rows of a report, added counter by counter. */ +function sumOf(rows: readonly EnvelopeRow[]): EnvelopeTally { + const zero = { tokens: 0, unknown_records: 0 }; + const add = (a: typeof zero, b: typeof zero) => ({ + tokens: a.tokens + b.tokens, + unknown_records: a.unknown_records + b.unknown_records, + }); + return rows.map(tallyOf).reduce( + (sum, row) => ({ + records: sum.records + row.records, + input: add(sum.input, row.input), + output: add(sum.output, row.output), + cache_read: add(sum.cache_read, row.cache_read), + cache_write: add(sum.cache_write, row.cache_write), + total: add(sum.total, row.total), + }), + { records: 0, input: zero, output: zero, cache_read: zero, cache_write: zero, total: zero } + ); +} + +/** What a person types to select one row, as the usage contract lists it. */ +function selectionOf(axis: ReportAxis, row: EnvelopeRow): Partial { + if (axis === "total") return {}; + if (axis === "day") return { period: { from: row.key, to: row.key } }; + if (row.kind === "absent") { + return { filters: { ...NO_FILTERS, absent: axis as ReportFilters["absent"] } }; + } + if (row.kind === "unattributed") return { filters: { ...NO_FILTERS, unattributed: row.reason } }; + return { filters: { ...NO_FILTERS, [axis]: row.key } }; +} + +const filters = fc + .record({ + task: fc.constantFrom(null, ...TASKS.filter((t) => t !== null), "t-none"), + ticket: fc.constantFrom(null, "T-1"), + person: fc.constantFrom(null, "p-1"), + model: fc.constantFrom(null, "m-1", "m-2"), + agent: fc.constantFrom(null, ...USAGE_AGENTS), + // Each names at most one value: a start two values share is refused, not combined. + session: fc.constantFrom(null, "a1b2c", "a1b2f", SESSIONS[2] as string, "zz"), + repository: fc.constantFrom(null, "r-a1", "r-b"), + absent: fc.constantFrom(null, "person" as const, "model" as const, "ticket" as const), + unattributed: fc.constantFrom( + null, + "any" as const, + "no-binding" as const, + "declared-none" as const + ), + }) + .filter((f: ReportFilters) => conflictsOf(f).length === 0); + +describe("a filtered report", () => { + it("adds up to the row it selects, and every axis of it adds up to its totals", async () => { + await fc.assert( + fc.asyncProperty( + ledgerOf, + period, + fc.constantFrom(...REPORT_AXES), + async (held, base, axis) => { + const report = await reporterOf(held); + const whole = await report({ axis, period: base }); + for (const row of whole.rows) { + const selection = { period: base, ...selectionOf(axis, row) }; + for (const each of REPORT_AXES) { + const narrowed = await report({ ...selection, axis: each }); + expect(narrowed.totals).toEqual(tallyOf(row)); + expect(sumOf(narrowed.rows)).toEqual(narrowed.totals); + } + } + } + ), + { numRuns: 60 } + ); + }, 60_000); + + it("keeps the calls every one of its filters keeps alone", async () => { + await fc.assert( + fc.asyncProperty(ledgerOf, period, filters, async (held, base, given) => { + const report = await reporterOf(held); + const kept = async (only: ReportFilters) => + (await report({ axis: "total", period: base, filters: only })).totals.output.tokens; + let alone = (await kept(NO_FILTERS)) as number; + for (const [flag, value] of Object.entries(given)) { + if (value !== null) alone &= await kept({ ...NO_FILTERS, [flag]: value }); + } + expect(await kept(given)).toBe(alone); + }), + { numRuns: 200 } + ); + }, 60_000); +}); diff --git a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts index 193464379..9dbb01c61 100644 --- a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts @@ -3,7 +3,10 @@ import { DirectoryResolver } from "../../../../../src/contexts/telemetry/applica import { IngestUsageUseCase } from "../../../../../src/contexts/telemetry/application/ingest-usage-use-case.js"; import { ReadUsageUseCase } from "../../../../../src/contexts/telemetry/application/read-usage-use-case.js"; import { DeclaredBindings } from "../../../../../src/contexts/telemetry/application/report/declared-bindings.js"; -import { ReportUsageUseCase } from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; +import { + ReportUsageUseCase, + type RequestedFilters, +} from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; import { SnapshotBindingsUseCase } from "../../../../../src/contexts/telemetry/application/snapshot-bindings-use-case.js"; import { declarationOf, @@ -11,7 +14,12 @@ import { } from "../../../../../src/contexts/telemetry/domain/declaration/task-declaration.js"; import { readClaudeUsageLine } from "../../../../../src/contexts/telemetry/domain/formats/claude-transcript-usage.js"; import type { Period } from "../../../../../src/contexts/telemetry/domain/report/period.js"; +import { + NO_FILTERS, + type ReportFilters, +} from "../../../../../src/contexts/telemetry/domain/report/report-filter.js"; import type { ReportAxis } from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; +import { repositoryIdOf } from "../../../../../src/contexts/telemetry/domain/repository-identity.js"; import { AUTUMN, cloneOf, @@ -93,9 +101,11 @@ function setup(options: { refused?: boolean } = {}) { ingest, ledger, new DeclaredBindings(sessions, snapshotStore), - identity + identity, + locator, + { refusedByEnvironment: options.refused ?? false } ); - return { transcripts, ledger, bindings, sessions, identity, report, events }; + return { transcripts, ledger, bindings, sessions, identity, report, events, locator }; } async function rows(s: ReturnType, axis: ReportAxis, period = ANY) { @@ -293,3 +303,234 @@ describe("reporting usage", () => { expect(s.events).toEqual([]); }); }); + +const SESSION_A = "a1b2c3d4-0000-4000-8000-000000000001"; +const SESSION_B = "a1b2ffff-0000-4000-8000-000000000002"; +const WIDGETS = repositoryIdOf({ + remote: "https://github.com/acme/widgets.git", + rootCommit: "c0ffee", +}) as string; +const filtered = (over: Partial): RequestedFilters => ({ ...NO_FILTERS, ...over }); +/** `--repository .`, run in `dir`. */ +const atHere = (dir: string, over: Partial = {}): RequestedFilters => ({ + ...NO_FILTERS, + ...over, + repository: null, + here: dir, +}); + +/** Two sessions in one repository: A on task `auth` for 11 then 22, B with no task for 33. */ +async function twoSessions(s: ReturnType) { + s.transcripts.files.set("/t/1.jsonl", [ + line("A", 10, "2026-10-01T10:00:00.000Z", SESSION_A), + line("B", 21, "2026-10-07T10:00:00.000Z", SESSION_A), + line("C", 32, "2026-10-07T11:00:00.000Z", SESSION_B), + ]); + await s.sessions.append( + SESSION_A, + declarationOf( + { kind: "task", task: "auth", ticket: "T-1" }, + new Date("2026-09-30T00:00:00.000Z"), + "command" + ) + ); +} + +describe("narrowing a report", () => { + it("keeps the calls every filter names, and still counts every call of the period in coverage", async () => { + const s = setup(); + await twoSessions(s); + const result = await s.report.execute({ + axis: "day", + period: { from: "2026-10-07", to: null }, + filters: filtered({ task: "auth" }), + }); + expect(result).toMatchObject({ + status: "reported", + report: { totals: { records: 1, total: { known: 22 } } }, + coverage: { records: 2 }, + }); + }); + + it("states every filter as null when none is given", async () => { + const s = setup(); + await twoSessions(s); + const result = await s.report.execute({ axis: "total", period: ANY }); + expect(result).toMatchObject({ + filters: NO_FILTERS, + report: { totals: { records: 3 } }, + coverage: { records: 3 }, + }); + }); + + it("resolves a session typed by its start to the one id it starts", async () => { + const s = setup(); + await twoSessions(s); + const result = await s.report.execute({ + axis: "total", + period: ANY, + filters: filtered({ session: "a1b2f" }), + }); + expect(result).toMatchObject({ + filters: { session: SESSION_B }, + report: { totals: { records: 1, total: { known: 33 } } }, + }); + }); + + it("refuses a start two sessions share, naming both in full and sorted", async () => { + const s = setup(); + await twoSessions(s); + expect( + await s.report.execute({ axis: "total", period: ANY, filters: filtered({ session: "a1b2" }) }) + ).toEqual({ status: "ambiguous", flag: "session", typed: "a1b2", ids: [SESSION_A, SESSION_B] }); + }); + + it("resolves a start among the calls of the period only", async () => { + const s = setup(); + await twoSessions(s); + // The day before the period is read, to settle it, and is still not the period. + s.transcripts.files + .get("/t/1.jsonl") + ?.splice(1, 1, line("B", 21, "2026-10-04T10:00:00.000Z", SESSION_A)); + const result = await s.report.execute({ + axis: "total", + period: { from: "2026-10-05", to: null }, + filters: filtered({ session: "a1b2" }), + }); + expect(result).toMatchObject({ status: "reported", filters: { session: SESSION_B } }); + }); + + it("reports nothing for a start no call has, and states it as typed", async () => { + const s = setup(); + await twoSessions(s); + const result = await s.report.execute({ + axis: "total", + period: ANY, + filters: filtered({ session: "ffff", repository: "0000" }), + }); + expect(result).toMatchObject({ + status: "reported", + filters: { session: "ffff", repository: "0000" }, + report: { totals: { records: 0 } }, + coverage: { records: 3 }, + }); + }); + + it("resolves a repository typed by its start to the id ingest stored", async () => { + const s = setup(); + await twoSessions(s); + const result = await s.report.execute({ + axis: "total", + period: ANY, + filters: filtered({ repository: WIDGETS.slice(0, 8) }), + }); + expect(result).toMatchObject({ + filters: { repository: WIDGETS }, + report: { totals: { records: 3 } }, + }); + }); + + it("names the repository a directory is in for `.`, by the id ingest stored for it", async () => { + const s = setup(); + await twoSessions(s); + const result = await s.report.execute({ + axis: "total", + period: ANY, + filters: atHere("/work/a"), + }); + expect(s.ledger.records[0]?.repository_id).toBe(WIDGETS); + expect(result).toMatchObject({ + filters: { repository: WIDGETS }, + report: { totals: { records: 3 } }, + }); + }); + + it("compares the repository of `.` in full, never as the start of another", async () => { + const s = setup(); + await twoSessions(s); + s.locator.directories.set("/work/fresh", { + status: "repository", + root: "/work/fresh", + mainRoot: "/work/fresh", + clone: cloneOf("/work/fresh/.git"), + remote: null, + rootCommit: WIDGETS.slice(0, 40), + }); + const result = await s.report.execute({ + axis: "total", + period: ANY, + filters: atHere("/work/fresh"), + }); + expect(result).toMatchObject({ + filters: { repository: WIDGETS.slice(0, 40) }, + report: { totals: { records: 0 } }, + }); + }); + + it.each([ + ["gone", { status: "gone" }], + ["outside any repository", { status: "outside-repository" }], + ["unreadable", { status: "unreadable" }], + [ + "in a repository with neither origin nor commit", + { + status: "repository", + root: "/work/b", + mainRoot: "/work/b", + clone: cloneOf("/work/b/.git"), + remote: null, + rootCommit: null, + }, + ], + ] as const)("refuses `.` in a directory %s, before anything is read", async (_, located) => { + const s = setup(); + await twoSessions(s); + s.locator.directories.set("/work/b", located); + s.events.length = 0; + expect( + await s.report.execute({ + axis: "total", + period: ANY, + filters: atHere("/work/b"), + }) + ).toEqual({ status: "not-a-repository" }); + expect(s.events).toEqual([]); + expect(s.locator.asked).toEqual(["/work/b"]); + }); + + it("refuses under AIDD_TELEMETRY=0 before `.` runs git", async () => { + const s = setup({ refused: true }); + expect( + await s.report.execute({ + axis: "total", + period: ANY, + filters: atHere("/work/a"), + }) + ).toEqual({ status: "refused" }); + expect(s.locator.asked).toEqual([]); + expect(s.events).toEqual([]); + }); + + it("refuses flags that select on one axis, before refusal and before anything is read", async () => { + for (const refused of [false, true]) { + const s = setup({ refused }); + expect( + await s.report.execute({ + axis: "total", + period: ANY, + filters: atHere("/work/a", { model: "m", absent: "model" }), + }) + ).toEqual({ status: "conflicting", flags: ["model", "absent"], absent: "model" }); + expect(s.locator.asked).toEqual([]); + expect(s.events).toEqual([]); + } + }); + + it("takes either a repository or `.`, never both", () => { + const typed: RequestedFilters = { ...NO_FILTERS, repository: "r" }; + const here: RequestedFilters = { ...NO_FILTERS, repository: null, here: "/work/a" }; + // @ts-expect-error `.` names the repository: a repository typed beside it is refused. + const both: RequestedFilters = { ...NO_FILTERS, repository: "r", here: "/work/a" }; + expect([typed, here, both]).toHaveLength(3); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts index c7d7d469c..d5dcfe824 100644 --- a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts @@ -13,10 +13,17 @@ import { reportEnvelopeOf } from "../../../../../src/contexts/telemetry/applicat import { ReportUsageUseCase } from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; import { SnapshotBindingsUseCase } from "../../../../../src/contexts/telemetry/application/snapshot-bindings-use-case.js"; import type { UsageLedger } from "../../../../../src/contexts/telemetry/domain/ports/usage-ledger.js"; +import { attribute } from "../../../../../src/contexts/telemetry/domain/report/attribution.js"; import { inPeriod, type Period, } from "../../../../../src/contexts/telemetry/domain/report/period.js"; +import { + conflictsOf, + matchesFilter, + type ReportFilters, + resolveId, +} from "../../../../../src/contexts/telemetry/domain/report/report-filter.js"; import { buildReport, REPORT_AXES, @@ -94,10 +101,31 @@ const period = fc a !== null && b !== null && a > b ? { from: b, to: a } : { from: a, to: b } ); +/** Flags a report may be narrowed by; a start like `s-` may name one session, both or none. */ +const filters = fc + .record({ + task: fc.constant(null), + ticket: fc.constant(null), + person: fc.constantFrom(null, "person-a", "person-b"), + model: fc.constantFrom(null, "m-1", "m-2"), + agent: fc.constantFrom(null, ...USAGE_AGENTS), + session: fc.constantFrom(null, "s-1", "s-", "s-2", "x"), + repository: fc.constantFrom(null, "repo-1", "repo-", "repo-2"), + absent: fc.constantFrom(null, "person" as const, "model" as const, "ticket" as const), + unattributed: fc.constantFrom( + null, + "any" as const, + "no-binding" as const, + "declared-none" as const + ), + }) + .filter((f: ReportFilters) => conflictsOf(f).length === 0); + const scenario = fc.record({ calls: fc.array(call, { minLength: 1, maxLength: 10 }), period, axis: fc.constantFrom(...REPORT_AXES), + filters, }); /** Every copy of every call, as the partition files that hold them. */ @@ -186,9 +214,9 @@ function ingestOf(ledger: UsageLedger): IngestUsageUseCase { } describe("a report on a bounded period", () => { - it("prints the envelope of the full ledger filtered to its period, byte for byte, and loses no call, when a call's copies are under a day apart", async () => { + it("prints the envelope of the full ledger filtered to its period and its filters, byte for byte, and loses no call, when a call's copies are under a day apart", async () => { await fc.assert( - fc.asyncProperty(scenario, async ({ calls, period, axis }) => { + fc.asyncProperty(scenario, async ({ calls, period, axis, filters }) => { const files = filesOf(calls); const declared = new DeclaredBindings(new InMemorySessions(), new InMemorySnapshots()); const identity = new InMemoryIdentity(); @@ -199,19 +227,39 @@ describe("a report on a bounded period", () => { ingestOf(settled), settled, declared, - identity - ).execute({ - axis, - period, - }); - if (bounded.status !== "reported") throw new Error("refused"); + identity, + new FakeLocator(), + { refusedByEnvironment: false } + ).execute({ axis, period, filters }); const full = await (await ledgerWith(files)).load(); const kept = full.records.filter((record) => inPeriod(record.at, period)); + // A session or repository typed by its start names a value among the period's calls. + const resolved: { -readonly [K in keyof ReportFilters]: ReportFilters[K] } = { ...filters }; + for (const flag of ["session", "repository"] as const) { + const typed = filters[flag]; + if (typed === null) continue; + const found = resolveId( + typed, + kept.map((record) => (flag === "session" ? record.session_id : record.repository_id)) + ); + if (found.kind === "ambiguous") { + expect(bounded).toEqual({ status: "ambiguous", flag, typed, ids: found.ids }); + return; + } + if (found.kind === "found") resolved[flag] = found.id; + } + if (bounded.status !== "reported") throw new Error(bounded.status); + const facts = await declared.read(); + const person = await identity.read(); + const selected = kept.filter((record) => + matchesFilter(resolved, record, attribute(record, facts), person) + ); const reference = reportEnvelopeOf({ status: "reported", period, - report: buildReport(kept, await declared.read(), await identity.read(), axis), + filters: resolved, + report: buildReport(selected, facts, person, axis), coverage: { filesRead: NOTHING_NEW.filesRead, records: kept.length, diff --git a/cli/tests/contexts/telemetry/domain/report/report-filter.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/report-filter.unit.test.ts new file mode 100644 index 000000000..7fb1b4cdc --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/report/report-filter.unit.test.ts @@ -0,0 +1,278 @@ +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import type { SessionDeclaration } from "../../../../../src/contexts/telemetry/domain/declaration/task-declaration.js"; +import { + type Attribution, + type AttributionFacts, + attribute, +} from "../../../../../src/contexts/telemetry/domain/report/attribution.js"; +import { + conflictsOf, + matchesFilter, + NO_FILTERS, + type ReportFilters, + resolveId, +} from "../../../../../src/contexts/telemetry/domain/report/report-filter.js"; +import { + type AxisValue, + buildReport, + REPORT_AXES, + type ReportAxis, +} from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; +import type { StoredUsage } from "../../../../../src/contexts/telemetry/domain/stored-usage.js"; +import { USAGE_AGENTS } from "../../../../../src/contexts/telemetry/domain/usage-record.js"; + +const call = (over: Partial = {}): StoredUsage => ({ + tool: "claude-code", + tool_version: null, + key: "k", + session_id: "s-1", + agent: "main", + at: "2026-10-02T10:00:00.000Z", + model: "m-1", + input: 1, + output: 10, + cache_read: 100, + cache_write: 1000, + cache_write_1h: null, + reasoning: null, + cwd: null, + git_branch: null, + repository_id: "repo-a", + ...over, +}); +const on = (task: string, ticket: string | null = null): Attribution => ({ + state: "attributed", + task, + ticket, +}); +const NO_BINDING: Attribution = { state: "unattributed", reason: "no-binding" }; +const DECLARED_NONE: Attribution = { state: "unattributed", reason: "declared-none" }; +const only = (over: Partial): ReportFilters => ({ ...NO_FILTERS, ...over }); +const keeps = ( + over: Partial, + record = call(), + attribution: Attribution = on("t-1", "T-1"), + person: string | null = "p-1" +) => matchesFilter(only(over), record, attribution, person); + +describe("a filter", () => { + it("keeps every call when no flag is given", () => { + expect(keeps({})).toBe(true); + expect(keeps({}, call({ model: null }), NO_BINDING, null)).toBe(true); + }); + + it("keeps a call whose axis has exactly the value named, case and all", () => { + expect(keeps({ task: "t-1" })).toBe(true); + expect(keeps({ task: "T-1" })).toBe(false); + expect(keeps({ ticket: "T-1" })).toBe(true); + expect(keeps({ ticket: "T-2" })).toBe(false); + expect(keeps({ person: "p-1" })).toBe(true); + expect(keeps({ person: "p-1" }, call(), on("t-1"), null)).toBe(false); + expect(keeps({ model: "m-1" })).toBe(true); + expect(keeps({ model: "m-2" })).toBe(false); + expect(keeps({ agent: "main" })).toBe(true); + expect(keeps({ agent: "subagent" })).toBe(false); + expect(keeps({ session: "s-1" })).toBe(true); + expect(keeps({ session: "s-" })).toBe(false); + expect(keeps({ repository: "repo-a" })).toBe(true); + expect(keeps({ repository: "repo-b" })).toBe(false); + }); + + it("names no task or ticket of a call with no task by the reason's words", () => { + expect(keeps({ task: "no-binding" }, call(), NO_BINDING)).toBe(false); + expect(keeps({ ticket: "no-binding" }, call(), NO_BINDING)).toBe(false); + }); + + it("keeps a call the axis has no value for under --absent, and only that call", () => { + expect(keeps({ absent: "model" }, call({ model: null }))).toBe(true); + expect(keeps({ absent: "model" })).toBe(false); + expect(keeps({ absent: "person" }, call(), on("t-1"), null)).toBe(true); + expect(keeps({ absent: "person" })).toBe(false); + expect(keeps({ absent: "ticket" }, call(), on("t-1"))).toBe(true); + expect(keeps({ absent: "ticket" })).toBe(false); + // Work with no task has no ticket either, but it is unattributed, not absent. + expect(keeps({ absent: "ticket" }, call(), NO_BINDING)).toBe(false); + }); + + it("keeps work with no task by its reason, or by either under any", () => { + expect(keeps({ unattributed: "no-binding" }, call(), NO_BINDING)).toBe(true); + expect(keeps({ unattributed: "no-binding" }, call(), DECLARED_NONE)).toBe(false); + expect(keeps({ unattributed: "declared-none" }, call(), DECLARED_NONE)).toBe(true); + expect(keeps({ unattributed: "declared-none" }, call(), NO_BINDING)).toBe(false); + expect(keeps({ unattributed: "any" }, call(), NO_BINDING)).toBe(true); + expect(keeps({ unattributed: "any" }, call(), DECLARED_NONE)).toBe(true); + expect(keeps({ unattributed: "any" })).toBe(false); + }); + + it("keeps a call only when every flag given keeps it", () => { + expect(keeps({ task: "t-1", model: "m-1", agent: "main" })).toBe(true); + expect(keeps({ task: "t-1", model: "m-2", agent: "main" })).toBe(false); + expect(keeps({ task: "t-2", model: "m-1" })).toBe(false); + expect(keeps({ task: "t-1", absent: "ticket" }, call(), on("t-1"))).toBe(true); + expect(keeps({ task: "t-1", absent: "ticket" })).toBe(false); + expect(keeps({ unattributed: "any", absent: "model" }, call({ model: null }), NO_BINDING)).toBe( + true + ); + expect(keeps({ unattributed: "any", absent: "model" }, call(), NO_BINDING)).toBe(false); + }); +}); + +describe("flags that select on one axis twice", () => { + it("are --unattributed with --task, --ticket or --absent ticket", () => { + expect(conflictsOf(only({ unattributed: "any", task: "t" }))).toEqual([ + ["task", "unattributed"], + ]); + expect(conflictsOf(only({ unattributed: "no-binding", ticket: "T" }))).toEqual([ + ["ticket", "unattributed"], + ]); + expect(conflictsOf(only({ unattributed: "any", absent: "ticket" }))).toEqual([ + ["absent", "unattributed"], + ]); + }); + + it("are a value and --absent of the same axis", () => { + expect(conflictsOf(only({ model: "m", absent: "model" }))).toEqual([["model", "absent"]]); + expect(conflictsOf(only({ person: "p", absent: "person" }))).toEqual([["person", "absent"]]); + expect(conflictsOf(only({ ticket: "T", absent: "ticket" }))).toEqual([["ticket", "absent"]]); + }); + + it("are none when each flag selects its own axis", () => { + expect(conflictsOf(NO_FILTERS)).toEqual([]); + expect(conflictsOf(only({ task: "t", ticket: "T" }))).toEqual([]); + expect(conflictsOf(only({ task: "t", absent: "ticket" }))).toEqual([]); + expect(conflictsOf(only({ unattributed: "any", absent: "model", model: null }))).toEqual([]); + expect( + conflictsOf( + only({ + task: "t", + ticket: "T", + person: "p", + model: "m", + agent: "main", + session: "s", + repository: "r", + }) + ) + ).toEqual([]); + }); + + it("lists every pair, each in the order the flags are printed", () => { + expect( + conflictsOf(only({ task: "t", ticket: "T", absent: "ticket", unattributed: "any" })) + ).toEqual([ + ["task", "unattributed"], + ["ticket", "absent"], + ["ticket", "unattributed"], + ["absent", "unattributed"], + ]); + }); +}); + +describe("an id typed in full or as a prefix", () => { + const short = "a".repeat(40); + const long = `${"a".repeat(40)}${"b".repeat(24)}`; + + it("is the value equal to it, even when it also starts another", () => { + expect(resolveId(short, [long, short])).toEqual({ kind: "found", id: short }); + }); + + it("is the one value it starts", () => { + expect(resolveId("aaaa", [long, "c0ffee"])).toEqual({ kind: "found", id: long }); + }); + + it("is ambiguous when it starts two or more, listing them in full and sorted", () => { + expect(resolveId("ab", ["abd", "abc", "xyz", "abc"])).toEqual({ + kind: "ambiguous", + ids: ["abc", "abd"], + }); + }); + + it("is none when it starts nothing, case included", () => { + expect(resolveId("AB", ["abc"])).toEqual({ kind: "none" }); + expect(resolveId("abc", [])).toEqual({ kind: "none" }); + }); +}); + +const SESSIONS = ["s-a", "s-b", "s-c"]; +const instant = fc + .integer({ min: 0, max: 3 * 86_400_000 }) + .map((ms) => new Date(Date.parse("2026-10-01T00:00:00.000Z") + ms).toISOString()); +const usage = fc + .record({ + key: fc.uuid(), + session_id: fc.constantFrom(...SESSIONS), + repository_id: fc.constantFrom("r-a", "r-b"), + agent: fc.constantFrom(...USAGE_AGENTS), + model: fc.option(fc.constantFrom("m-1", "m-2"), { nil: null }), + at: instant, + input: fc.option(fc.nat(1000), { nil: null, freq: 4 }), + output: fc.nat(1000), + }) + .map( + (r): StoredUsage => ({ + ...r, + tool: "claude-code", + tool_version: null, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: null, + git_branch: null, + }) + ); +const facts = fc + .array( + fc.record({ + session_id: fc.constantFrom(...SESSIONS), + task: fc.constantFrom("t-1", "t-2", null), + ticket: fc.option(fc.constantFrom("T-1", "T-2"), { nil: null }), + declared_at: instant, + }), + { maxLength: 5 } + ) + .map( + (declarations): AttributionFacts => ({ + declarations: declarations.map( + (d): SessionDeclaration => ({ ...d, none: d.task === null, by: "command" }) + ), + carries: [], + branches: new Map(), + }) + ); + +/** The flags a person types to select one row: the row's own value, as the contract lists it. */ +function filtersOfRow(axis: ReportAxis, value: AxisValue): ReportFilters { + if (value.kind === "absent") return only({ absent: axis as ReportFilters["absent"] }); + if (value.kind === "unattributed") return only({ unattributed: value.reason }); + return only({ [axis]: value.value }); +} + +describe("selecting a row", () => { + it("keeps exactly the calls of that row, so it adds up to that row", () => { + fc.assert( + fc.property( + fc.array(usage, { maxLength: 30 }), + facts, + fc.option(fc.constant("p-1"), { nil: null }), + fc.constantFrom(...REPORT_AXES.filter((axis) => axis !== "total" && axis !== "day")), + (records, held, person, axis) => { + for (const row of buildReport(records, held, person, axis).rows) { + const filters = filtersOfRow(axis, row.value); + const kept = records.filter((record) => + matchesFilter(filters, record, attribute(record, held), person) + ); + const { records: n, counters, total } = row; + expect(buildReport(kept, held, person, "total").totals).toEqual({ + records: n, + counters, + total, + }); + } + } + ), + { numRuns: 200 } + ); + }); +}); diff --git a/cli/tests/e2e/telemetry-report.e2e.test.ts b/cli/tests/e2e/telemetry-report.e2e.test.ts index dfe5ee5b1..a6dbe429f 100644 --- a/cli/tests/e2e/telemetry-report.e2e.test.ts +++ b/cli/tests/e2e/telemetry-report.e2e.test.ts @@ -256,8 +256,8 @@ async function held(): Promise> { return found; } -function aidd(args: string[], extra: Record = {}) { - return runCli(["telemetry", ...args], repo, env.fakeHome, { +function aidd(args: string[], extra: Record = {}, cwd = repo) { + return runCli(["telemetry", ...args], cwd, env.fakeHome, { env: { AIDD_TELEMETRY_DIR: telemetry, AIDD_USER_CONFIG_DIR: userConfig, @@ -294,6 +294,7 @@ interface Row { interface Envelope { version: number; axis: string; + filters: Record; rows: Row[]; totals: Row; unknown_records: number; @@ -477,6 +478,123 @@ describe("aidd telemetry report", () => { }); }); +describe("aidd telemetry report, narrowed", () => { + const tallyOf = ({ kind: _k, key: _key, reason: _r, ...tally }: Row) => tally; + async function narrowed(...args: string[]): Promise { + const run = await aidd(["report", ...WINDOW, "--json", ...args]); + expect(run.exitCode, run.stderr).toBe(0); + return JSON.parse(run.stdout) as Envelope; + } + + it("adds up to the row it selects, a session typed by the eight characters the text shows", async () => { + await declareBranch(); + for (const axis of ["ticket", "session", "model"]) { + for (const row of (await json(axis)).rows) { + const flags = + row.kind === "absent" + ? ["--absent", axis] + : row.kind === "unattributed" + ? ["--unattributed", row.reason as string] + : [ + `--${axis}`, + axis === "session" ? (row.key as string).slice(0, 8) : (row.key as string), + ]; + const envelope = await narrowed(...flags); + expect(envelope.totals, flags.join(" ")).toEqual(tallyOf(row)); + expect(envelope.coverage.records).toBe(CALLS.length); + } + } + }); + + it("answers what one task cost per day, and states every filter, as resolved", async () => { + await declareBranch(); + const envelope = await narrowed( + "--task", + "checkout", + "--axis", + "day", + "--session", + S_BRANCH.slice(0, 8) + ); + expect(envelope.filters).toEqual({ + task: "checkout", + ticket: null, + person: null, + model: null, + agent: null, + session: S_BRANCH, + repository: null, + absent: null, + unattributed: null, + }); + expect(envelope.rows.map((row) => [row.key, row.records])).toEqual([ + ["2026-10-07", 1], + ["2026-10-08", 1], + ]); + const text = await aidd(["report", ...WINDOW, "--task", "checkout"]); + expect(text.stdout).toContain("Selected: task = checkout"); + const none = await aidd(["report", ...WINDOW, "--task", "nothing"]); + expect(none.exitCode).toBe(0); + expect(none.stdout).toContain("No call in this period matches these filters."); + }); + + it("names with `.` the repository ingest stored, from the main tree and from a linked worktree", async () => { + const [stored] = (await json("repository")).rows; + const linked = join(env.tempDir, "linked"); + git(repo, gitEnv, "worktree", "add", "-q", "-b", "wt", linked); + for (const cwd of [repo, linked]) { + const run = await aidd(["report", ...WINDOW, "--json", "--repository", "."], {}, cwd); + expect(run.exitCode, run.stderr).toBe(0); + const envelope = JSON.parse(run.stdout) as Envelope; + expect(envelope.filters.repository).toBe(stored?.key); + expect(envelope.totals.records).toBe(CALLS.length); + } + }); + + it("refuses a start two sessions share, naming both in full and sorted", async () => { + const run = await aidd(["report", ...WINDOW, "--session", "a"]); + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain( + `--session a starts 2 sessions of this period: ${S_BRANCH}, ${S_REDECLARED}.` + ); + }); + + it.each([ + ["a flag given twice", ["--task", "a", "--task", "b"], "--task"], + ["an empty value", ["--model", " "], "--model"], + ["an unknown choice", ["--agent", "robot"], "--agent"], + ["flags on one axis", ["--unattributed", "--task", "a"], "--task and --unattributed"], + ])("refuses %s with exit 1, naming the flag, and reads nothing", async (_, args, flag) => { + const before = await held(); + const run = await aidd(["report", ...args]); + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain(flag); + expect(await held()).toEqual(before); + }); + + it("refuses `.` outside any repository with exit 1, and reads nothing", async () => { + const before = await held(); + const outside = join(env.tempDir, "outside"); + await mkdir(outside, { recursive: true }); + const run = await aidd(["report", "--repository", "."], {}, outside); + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("--repository ."); + expect(await held()).toEqual(before); + }); + + it("runs no git for `.` under AIDD_TELEMETRY=0", async () => { + const outside = join(env.tempDir, "outside"); + await mkdir(outside, { recursive: true }); + const run = await aidd( + ["report", "--json", "--repository", "."], + { AIDD_TELEMETRY: "0" }, + outside + ); + expect(run.exitCode).toBe(0); + expect(JSON.parse(run.stdout)).toEqual({ version: 1, refused: "AIDD_TELEMETRY=0" }); + }); +}); + describe("aidd telemetry identity", () => { it("shows the person axis as not set until an identity is chosen", async () => { const before = rowsOf(await json("person")); diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index d3cd01093..7c3876905 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -167,7 +167,7 @@ { "invocation": "aidd telemetry report", "exitCode": 0, - "help": "Usage: aidd telemetry report [options]\n\nShow what the work consumed, split along one axis, after reading new transcripts\n\nOptions:\n --from First day to include, as YYYY-MM-DD (UTC)\n --to Last day to include, as YYYY-MM-DD (UTC)\n --days The last n days, today included\n --axis What to split by (choices: \"total\", \"person\", \"session\",\n \"model\", \"agent\", \"day\", \"repository\", \"task\", \"ticket\",\n default: \"total\")\n --json Print a versioned JSON envelope instead of text (default:\n false)\n -h, --help display help for command" + "help": "Usage: aidd telemetry report [options]\n\nShow what the work consumed, split along one axis, after reading new transcripts\n\nOptions:\n --from First day to include, as YYYY-MM-DD (UTC)\n --to Last day to include, as YYYY-MM-DD (UTC)\n --days The last n days, today included\n --axis What to split by (choices: \"total\", \"person\",\n \"session\", \"model\", \"agent\", \"day\", \"repository\",\n \"task\", \"ticket\", default: \"total\")\n --task Only the calls of this task\n --ticket Only the calls of this ticket, as declared\n --person Only the calls of this identity\n --model Only the calls of this model\n --agent Only the calls of this agent (choices: \"main\",\n \"subagent\", \"advisor\")\n --session Only the calls of this session, or a start of its id\n --repository Only the calls of this repository, a start of its id,\n or . for the one here\n --absent Only the calls with no value on this axis (choices:\n \"person\", \"model\", \"ticket\")\n --unattributed [reason] Only the work with no task, for this reason (choices:\n \"no-binding\", \"declared-none\", \"any\", preset: \"any\")\n --json Print a versioned JSON envelope instead of text\n (default: false)\n -h, --help display help for command" }, { "invocation": "aidd telemetry status", diff --git a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts index 21767357a..444ebf72f 100644 --- a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts @@ -2,6 +2,10 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + conflictsOf, + type ReportFilters, +} from "../../../src/contexts/telemetry/domain/report/report-filter.js"; const ingest = vi.fn(); const declare = vi.fn(); @@ -89,9 +93,21 @@ const NOT_STORED = { undated: 0, }; const ZERO = { known: 0, unknownRecords: 0 }; +const NO_FILTERS = { + task: null, + ticket: null, + person: null, + model: null, + agent: null, + session: null, + repository: null, + absent: null, + unattributed: null, +}; const reported = { status: "reported", period: { from: null, to: null }, + filters: NO_FILTERS, report: { axis: "total", rows: [], @@ -115,7 +131,11 @@ describe("aidd telemetry report", () => { it("asks for every day on the total axis when nothing is given, and prints text", async () => { report.mockResolvedValue(reported); expect(await run("report")).toBeNull(); - expect(report).toHaveBeenCalledWith({ axis: "total", period: { from: null, to: null } }); + expect(report).toHaveBeenCalledWith({ + axis: "total", + period: { from: null, to: null }, + filters: NO_FILTERS, + }); expect(stdout()).toContain("Usage, all days, by total"); }); @@ -125,6 +145,7 @@ describe("aidd telemetry report", () => { expect(report).toHaveBeenCalledWith({ axis: "task", period: { from: "2026-10-01", to: "2026-10-07" }, + filters: NO_FILTERS, }); }); @@ -159,6 +180,110 @@ describe("aidd telemetry report", () => { }); }); +describe("aidd telemetry report, narrowed", () => { + const asked = () => (report.mock.calls[0] as [{ filters: unknown }])[0].filters; + /** Commander refuses a flag value itself: with `exitOverride` it throws its exit code. */ + async function refused(...args: string[]): Promise { + try { + const code = await run("report", ...args); + if (code === null) throw new Error("not refused"); + return code; + } catch (error) { + if (error instanceof Error && "exitCode" in error) return error.exitCode as number; + throw error; + } + } + + it("passes every flag it is given, trimmed as a declaration is", async () => { + report.mockResolvedValue(reported); + await run( + "report", + ...["--task", " auth refactor ", "--ticket", "PROJ-1", "--person", "p-1"], + ...["--model", "m-1", "--agent", " subagent", "--session", "a1b2", "--repository", "r-1"] + ); + expect(asked()).toEqual({ + ...NO_FILTERS, + task: "auth refactor", + ticket: "PROJ-1", + person: "p-1", + model: "m-1", + agent: "subagent", + session: "a1b2", + repository: "r-1", + }); + vi.clearAllMocks(); + await run("report", "--absent", " person "); + expect(asked()).toEqual({ ...NO_FILTERS, absent: "person" }); + }); + + it("asks for the repository the command runs in for `.`", async () => { + report.mockResolvedValue(reported); + await run("report", "--repository", " . "); + expect(asked()).toEqual({ ...NO_FILTERS, here: process.cwd() }); + }); + + it("reads a bare --unattributed as either reason, and takes one reason", async () => { + report.mockResolvedValue(reported); + await run("report", "--unattributed"); + expect(asked()).toEqual({ ...NO_FILTERS, unattributed: "any" }); + vi.clearAllMocks(); + await run("report", "--unattributed", "declared-none", "--axis", "day"); + expect(asked()).toEqual({ ...NO_FILTERS, unattributed: "declared-none" }); + }); + + it.each([ + ["an open flag given twice", ["--task", "a", "--task", "b"], "--task"], + ["a closed flag given twice", ["--agent", "main", "--agent", "main"], "--agent"], + ["a bare flag given twice", ["--unattributed", "--unattributed"], "--unattributed"], + ["a value empty once trimmed", ["--ticket", " "], "--ticket"], + ["an agent no call can have", ["--agent", "robot"], "--agent"], + ["an axis no call lacks", ["--absent", "session"], "--absent"], + ["a reason no call has", ["--unattributed", "no-task"], "--unattributed"], + ])("refuses %s with exit 1, naming the flag, before reading anything", async (_, args, flag) => { + expect(await refused(...args)).toBe(1); + expect(stderr()).toContain(flag); + expect(report).not.toHaveBeenCalled(); + }); + + it("refuses a bare flag given twice by its name, not by the value it was preset to", async () => { + expect(await refused("--unattributed", "--unattributed")).toBe(1); + expect(stderr()).toContain("--unattributed is given twice: give it once."); + expect(stderr()).not.toContain("null"); + }); + + it.each([ + [["--unattributed", "--task", "a"], "--task and --unattributed"], + [["--ticket", "T", "--unattributed", "no-binding"], "--ticket and --unattributed"], + [["--absent", "ticket", "--unattributed"], "--absent ticket and --unattributed"], + [["--model", "m", "--absent", "model"], "--model and --absent model"], + ])( + "exits 1 on %j, which select on one axis, naming both as the report refuses them", + async (args, flags) => { + // The use case's own refusal, as `report-usage-use-case.unit.test.ts` pins it. + report.mockImplementation(async ({ filters }: { filters: ReportFilters }) => { + const [conflict] = conflictsOf(filters); + return conflict === undefined + ? reported + : { status: "conflicting", flags: conflict, absent: filters.absent }; + }); + expect(await run("report", ...args)).toBe(1); + expect(report).toHaveBeenCalledTimes(1); + expect(stderr()).toContain(flags); + expect(stdout()).toBe(""); + } + ); + + it.each([ + [{ status: "ambiguous", flag: "session", typed: "a", ids: ["a1", "a2"] }, "a1, a2"], + [{ status: "not-a-repository" }, "--repository ."], + ])("exits 1 when the report cannot name what was asked: %j", async (result, said) => { + report.mockResolvedValue(result); + expect(await run("report", "--json", "--session", "a")).toBe(1); + expect(stderr()).toContain(said); + expect(stdout()).toBe(""); + }); +}); + describe("aidd telemetry identity", () => { it("shows who is named when given nothing", async () => { identity.show.mockResolvedValue({ status: "unset" }); diff --git a/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts b/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts index 04c0fdb5b..3f0cd13c5 100644 --- a/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { ReportResult } from "../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; import type { Period } from "../../../../src/contexts/telemetry/domain/report/period.js"; +import { + NO_FILTERS, + type ReportFilters, +} from "../../../../src/contexts/telemetry/domain/report/report-filter.js"; import { type AxisValue, REPORT_AXES, @@ -48,11 +52,13 @@ function result( rows: [AxisValue, Tally][], totals: Tally, coverage: Partial = {}, - period: Period = BOTH + period: Period = BOTH, + filters: ReportFilters = NO_FILTERS ): ReportResult { return { status: "reported", period, + filters, report: { axis, rows: rows.map(([value, t]) => ({ value, ...t })), totals }, coverage: { ...COVERAGE, ...coverage }, }; @@ -268,4 +274,93 @@ describe("printing a usage report", () => { printUsageReport(output, { status: "refused" }); expect(output.lines).toEqual(["AIDD_TELEMETRY=0: nothing was read and nothing is reported."]); }); + + it("names the flag and every id a start names, in full, when it names several", () => { + const output = new CapturingOutput(false); + printUsageReport(output, { + status: "ambiguous", + flag: "session", + typed: "a1b2", + ids: ["a1b2c3d4-0000", "a1b2ffff-0000"], + }); + expect(output.at("error")).toEqual([ + "--session a1b2 starts 2 sessions of this period: a1b2c3d4-0000, a1b2ffff-0000. Type more of it.", + ]); + expect(output.lines).toHaveLength(1); + const repositories = new CapturingOutput(false); + printUsageReport(repositories, { + status: "ambiguous", + flag: "repository", + typed: "ab", + ids: ["abc", "abd"], + }); + expect(repositories.at("error")).toEqual([ + "--repository ab starts 2 repositories of this period: abc, abd. Type more of it.", + ]); + }); + + it("names the two flags that select on one axis, with the axis --absent names", () => { + const output = new CapturingOutput(false); + printUsageReport(output, { + status: "conflicting", + flags: ["ticket", "absent"], + absent: "ticket", + }); + expect(output.at("error")).toEqual([ + "--ticket and --absent ticket select on the same axis, so together they keep nothing: give one of them.", + ]); + expect(output.lines).toHaveLength(1); + }); + + it("says `.` names no repository where the command runs", () => { + const output = new CapturingOutput(false); + printUsageReport(output, { status: "not-a-repository" }); + expect(output.at("error")).toEqual([ + "--repository . names the repository this directory is in, and it is in none: not a git repository, unreadable, or with neither an origin nor a commit.", + ]); + expect(output.lines).toHaveLength(1); + }); + + it("names each filter as its row would label it, on one line before the table", () => { + const filters: ReportFilters = { + ...NO_FILTERS, + task: "auth\nrefactor", + ticket: "PROJ-1", + session: "a1b2c3d4-0000-4000-8000-000000000001", + repository: "f".repeat(64), + agent: "subagent", + absent: "model", + person: "p-1", + }; + const lines = printed(result("day", [[value("2026-10-02"), one]], one, {}, BOTH, filters)); + expect(lines.slice(0, 3)).toEqual([ + "Usage, 2026-10-01 to 2026-10-07, by day", + "Selected: task = auth refactor, ticket = PROJ-1, person = p-1, agent = subagent, session = a1b2c3d4, repository = ffffffff, model = unknown model", + "", + ]); + const absentOf = (absent: ReportFilters["absent"]) => + printed(result("total", [], one, {}, BOTH, { ...NO_FILTERS, absent }))[1]; + expect(absentOf("person")).toBe("Selected: person = not set"); + expect(absentOf("ticket")).toBe("Selected: ticket = no ticket"); + const unattributed = (reason: ReportFilters["unattributed"]) => + printed(result("total", [], one, {}, BOTH, { ...NO_FILTERS, unattributed: reason }))[1]; + expect(unattributed("no-binding")).toBe("Selected: task = unattributed: no task declared"); + expect(unattributed("declared-none")).toBe("Selected: task = unattributed: declared no task"); + expect(unattributed("any")).toBe("Selected: task = unattributed, either reason"); + }); + + it("prints no selection line when no filter is given", () => { + expect(printed(result("total", [[value("total"), one]], one))[1]).toBe(""); + }); + + it("says no call matches when filters keep none, and still shows the coverage", () => { + const filters = { ...NO_FILTERS, task: "nothing" }; + expect(printed(result("total", [], tally(0, [0, 0, 0, 0]), {}, BOTH, filters))).toEqual([ + "Usage, 2026-10-01 to 2026-10-07, by total", + "Selected: task = nothing", + "No call in this period matches these filters.", + "", + "Read 2 transcripts just now; 3 calls in this period; oldest transcript on disk: 2026-09-20.", + ]); + }); }); diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index 20a59b4cd..f4bb1917c 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -36,7 +36,7 @@ flowchart LR | `aidd telemetry on` / `off` | start or stop measuring this clone; what was measured stays | | `aidd telemetry status` | whether this clone is measured, since when, and its task | | `aidd telemetry task [--ticket ]` | declare the task; `--none` for no task | -| `aidd telemetry report [--axis ] [--days ]` | what the work consumed, split along one axis; `--json` for the envelope | +| `aidd telemetry report [--axis ] [--days ] [--task ]` | what the work consumed, split along one axis, narrowed by `--task`, `--ticket`, `--repository .` and the other filters of `--help`; `--json` for the envelope | | `aidd telemetry identity ` / `--off` | be named on your own measurement, or not | | `aidd telemetry forget` / `--yes` | show what is kept, then erase it | diff --git a/plugins/aidd-telemetry/skills/01-usage/SKILL.md b/plugins/aidd-telemetry/skills/01-usage/SKILL.md index 75e536ca4..627c1e31c 100644 --- a/plugins/aidd-telemetry/skills/01-usage/SKILL.md +++ b/plugins/aidd-telemetry/skills/01-usage/SKILL.md @@ -23,7 +23,7 @@ Run the flow above. Read only the next action file. | Action | Does | | --- | --- | -| frame | map the question to a period and one axis, and name the command | +| frame | map the question to a period, one axis and the filters it fixes, and name the command | | answer | run it, read the JSON, and state the figures with what is unknown | ## Transversal rules diff --git a/plugins/aidd-telemetry/skills/01-usage/actions/answer.md b/plugins/aidd-telemetry/skills/01-usage/actions/answer.md index b299775bb..3f95f7f62 100644 --- a/plugins/aidd-telemetry/skills/01-usage/actions/answer.md +++ b/plugins/aidd-telemetry/skills/01-usage/actions/answer.md @@ -17,7 +17,7 @@ The figures the report holds for the question, each counter apart, followed by e 2. **Check.** Read [the envelope reference](../references/envelope.md) for the fields. - `refused` present: measurement is refused by the environment. Say so and give no figure. - `version` other than `1`: say the report has a shape this skill does not know, and give no figure. -3. **State.** Give the rows and the `totals` as the report holds them: input, output, cache read, cache write and `total`, each with its token count. Name the period and the axis. +3. **State.** Give the rows and the `totals` as the report holds them: input, output, cache read, cache write and `total`, each with its token count. Name the period, the axis, and each filter as `filters` prints it. 4. **Say the unknowns.** Say each of these that applies, in this order: - a counter whose `unknown_records` is above `0`: its figure is a lower bound, and that many calls did not report it; - `unknown_records` on the totals above `0`: the same for the total; @@ -25,6 +25,7 @@ The figures the report holds for the question, each counter apart, followed by e - a row of kind `absent`: that the axis had no value for those calls; - `coverage.unrecognised_shapes`, `coverage.skipped_ledger_lines` or any `coverage.not_stored` entry above `0`: that some lines were read and left out, with the counts; - `coverage.records` of `0`: that no call is recorded for the period, and why that may be, from the reference. + - `totals.records` of `0` with `coverage.records` above `0`: that a filter matched no call of the period, naming each as `filters` prints it, from the reference. 5. **Bound the past.** When the period starts before `coverage.oldest_transcript_at`, say that Claude Code may have removed older session files, so earlier figures can be incomplete. 6. **Offer the fix.** For work with no task, say it can be attributed afterwards by declaring the task from that branch with `aidd telemetry task [--ticket ]`. Declare nothing yourself. diff --git a/plugins/aidd-telemetry/skills/01-usage/actions/frame.md b/plugins/aidd-telemetry/skills/01-usage/actions/frame.md index db306b152..bda215e86 100644 --- a/plugins/aidd-telemetry/skills/01-usage/actions/frame.md +++ b/plugins/aidd-telemetry/skills/01-usage/actions/frame.md @@ -1,6 +1,6 @@ # Frame -Turn the question into one period and one axis, and name the exact command. +Turn the question into one period, one axis and the filters it fixes, and name the exact command. ## Input @@ -8,7 +8,7 @@ The person's question, in their words. ## Output -The command to run, and the period and axis it stands for, said back to the person in one line. +The command to run, and the period, axis and filters it stands for, said back to the person in one line. ## Process @@ -30,9 +30,22 @@ The command to run, and the period and axis it stands for, said back to the pers | each repository | `--axis repository` | | each person | `--axis person` | -3. **One axis.** When the question crosses two axes, such as tasks per day, run one report per axis and answer each. Say that the report has no cross of two, and do not combine them. +3. **Filters.** A value the question fixes on another axis becomes a filter, so "what did task `auth` cost per day" is `--task auth --axis day`. Filters combine with AND, one per axis. + + | The question fixes | Filter | + | --- | --- | + | one task, ticket, person or model | `--task `, `--ticket `, `--person `, `--model `, as the report's `key` prints it | + | the main session, its sub-agents or the advisor | `--agent main`, `--agent subagent`, `--agent advisor` | + | one session or repository | `--session `, `--repository `: the full id or its start, as the text report shows it | + | the repository the person is in | `--repository .` | + | work with no task | `--unattributed`, or `--unattributed no-binding` / `--unattributed declared-none` for one reason | + | calls with no model, no identity, or a task with no ticket | `--absent model`, `--absent person`, `--absent ticket` | + + - One day is a period, `--from D --to D`, not a filter. + - `--unattributed` cannot be combined with `--task`, `--ticket` or `--absent ticket`; a value and `--absent` of the same axis cannot be combined either. +4. **One open axis.** When the question splits by two axes with neither fixed, such as tasks per day, run one report per axis and answer each. Say that the report has no cross of two, and do not combine them. - A question about a currency cost, a skill, a step, a prompt or a file has no axis: say what the report can split by, and stop. -4. **Name.** Write the command as `aidd telemetry report --json` plus the flags chosen, e.g. `aidd telemetry report --days 7 --axis task --json`. +5. **Name.** Write the command as `aidd telemetry report --json` plus the flags chosen, e.g. `aidd telemetry report --days 7 --axis task --json`. ## Test @@ -41,4 +54,8 @@ The command to run, and the period and axis it stands for, said back to the pers | "What did last week cost in tokens?" | `aidd telemetry report --days 7 --axis total --json`, with the window stated | | "Which tasks used the most?" | `--axis task`, no period, stated as every recorded day | | "How much money was that?" | No command; the answer says the report holds token counts only | -| "Tasks per day" | Two commands, `--axis task` and `--axis day`, and no figure combined from both | +| "Tasks per day", no task named | Two commands, `--axis task` and `--axis day`, and no figure combined from both | +| "What did task auth cost per day?" | `aidd telemetry report --task auth --axis day --json`, no period, stated as every recorded day | +| "Which models did ticket PROJ-7 use this week?" | `aidd telemetry report --days 7 --ticket PROJ-7 --axis model --json` | +| "What did this repository cost per task?" | `aidd telemetry report --repository . --axis task --json` | +| "How much work had no task, per session?" | `aidd telemetry report --unattributed --axis session --json` | diff --git a/plugins/aidd-telemetry/skills/01-usage/references/envelope.md b/plugins/aidd-telemetry/skills/01-usage/references/envelope.md index ffcd03ecf..929c21b84 100644 --- a/plugins/aidd-telemetry/skills/01-usage/references/envelope.md +++ b/plugins/aidd-telemetry/skills/01-usage/references/envelope.md @@ -10,8 +10,9 @@ What `aidd telemetry report --json` prints, and what each part means. Version `1 | `refused` | Only present, with `version`, when measurement is refused by the environment; nothing was read | | `period` | `from` and `to`, UTC days, `null` for open | | `axis` | What `rows` split by | -| `rows` | One per value of the axis; every axis adds up to the same `totals` | -| `totals` | The whole period | +| `filters` | What the report was narrowed to: `task`, `ticket`, `person`, `model`, `agent`, `session`, `repository`, `absent`, `unattributed`. `null` when not given, else the value as resolved: a full id for a start or `.`, the axis for `absent`, the reason or `any` for `unattributed` | +| `rows` | One per value of the axis, of the calls the filters keep; every axis adds up to the same `totals` | +| `totals` | The calls of the period the filters keep | | `unknown_records` | Calls with at least one counter unknown | | `coverage` | What the figures rest on | @@ -39,7 +40,7 @@ What `aidd telemetry report --json` prints, and what each part means. Version `1 | Field | Meaning | | --- | --- | | `files_read` | Session files read by this run | -| `records` | Calls recorded in the period | +| `records` | Calls recorded in the period, whether the filters keep them or not | | `unrecognised_shapes` | Lines of a shape the reader did not recognise, reported and never guessed | | `not_stored` | Calls read and left out, by reason: `outside-repo`, `never-seen-alive`, `no-consent`, `consent-closed`, `unreadable-consent`, `no-cwd`, `undated` | | `consent_log_damaged` | `true` while `ledger/consents.jsonl` cannot be trusted and nothing is stored | @@ -54,4 +55,5 @@ What `aidd telemetry report --json` prints, and what each part means. Version `1 | `not_stored.consent-closed` above `0` | The clone opted in once and its consent was closed: `aidd telemetry on` in that clone measures again, from then on | | `not_stored.unreadable-consent` above `0` | A clone's git config or git directory cannot be read: nothing is stored for it until it can | | `consent_log_damaged` is `true` | A line of the consent log `ledger/consents.jsonl` is not an event, so nothing is stored for any clone. Recovery is `aidd telemetry forget --yes`, then `aidd telemetry on` in each clone: nothing from before is stored. Do not edit the log by hand | +| `totals.records` of `0` and `coverage.records` above `0`, with a filter given | No call of the period matches the filters: a value no call has, or a start of a session or repository id that names none, printed as typed in `filters` | | `records` of `0` and `files_read` of `0` | No clone on this machine has opted in, so no session file is read (`aidd telemetry status` says so in the clone); or no Claude Code session file was written since the first `aidd telemetry on` | diff --git a/scripts/__tests__/check-claude-tokens-end-to-end.test.js b/scripts/__tests__/check-claude-tokens-end-to-end.test.js index 258d14f08..30816be9c 100644 --- a/scripts/__tests__/check-claude-tokens-end-to-end.test.js +++ b/scripts/__tests__/check-claude-tokens-end-to-end.test.js @@ -20,6 +20,7 @@ function reports(row = exact(), totals = exact()) { billed: BILLED, byTask: { totals, rows: [{ kind: "value", key: "demo", ...row }] }, byTicket: { totals, rows: [{ kind: "value", key: "T-1", ...row }] }, + here: { filters: { task: "demo", repository: "a".repeat(64) }, totals: row }, }; } @@ -48,6 +49,24 @@ describe("the report of a real claude run, judged against what the API billed", assert.equal(judge(unattributed).code, 1); }); + it("fails when the report narrowed to the task in this repository misses a call", () => { + const narrowed = reports(); + narrowed.here.totals = exact({ records: 1 }); + assert.match(judge(narrowed).message, /task demo here records 1, billed 2/u); + }); + + it("passes when `.` names a repository with no hosted origin by its root commit", () => { + const narrowed = reports(); + narrowed.here.filters.repository = "b".repeat(40); + assert.equal(judge(narrowed).code, 0); + }); + + it("fails when `.` is not resolved to the repository's id", () => { + const narrowed = reports(); + narrowed.here.filters.repository = "."; + assert.match(judge(narrowed).message, /--repository \. resolved to "\."/u); + }); + it("calls itself broken when the API billed fewer calls than prompts were sent", () => { assert.equal(judge({ ...reports(), billed: BILLED.slice(0, 1) }).code, 2); }); diff --git a/scripts/check-claude-tokens-end-to-end.cjs b/scripts/check-claude-tokens-end-to-end.cjs index d55d32aba..499f6d2cc 100644 --- a/scripts/check-claude-tokens-end-to-end.cjs +++ b/scripts/check-claude-tokens-end-to-end.cjs @@ -259,8 +259,9 @@ function compare(label, actual, expected) { return wrong; } -/** The verdict on the task and ticket reports against the billed calls. */ -function judge({ billed, byTask, byTicket, prompts = PROMPTS.length }) { +/** The verdict on the task and ticket reports, and on the report narrowed to the task in this + * repository, against the billed calls. */ +function judge({ billed, byTask, byTicket, here, prompts = PROMPTS.length }) { if (billed.length < prompts) return { code: BROKEN, message: `the API billed ${billed.length} calls for ${prompts} prompts` }; const expected = expectedTotals(billed); const rowOf = (report, key) => report?.rows?.find((row) => row.kind === "value" && row.key === key); @@ -268,9 +269,14 @@ function judge({ billed, byTask, byTicket, prompts = PROMPTS.length }) { ...compare("totals", byTask?.totals, expected), ...compare("task demo", rowOf(byTask, "demo"), expected), ...compare("ticket T-1", rowOf(byTicket, "T-1"), expected), + ...compare("task demo here", here?.totals, expected), + // A hosted origin's hash, or the root commit of a repository with none. + ...(/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u.test(here?.filters?.repository ?? "") + ? [] + : [`--repository . resolved to ${JSON.stringify(here?.filters?.repository)}`]), ]; if (wrong.length > 0) return { code: WRONG, message: wrong.join("; ") }; - return { code: 0, message: `${expected.records} calls, ${expected.input + expected.output + expected.cache_read + expected.cache_write} tokens, all on task demo, ticket T-1` }; + return { code: 0, message: `${expected.records} calls, ${expected.input + expected.output + expected.cache_read + expected.cache_write} tokens, all on task demo, ticket T-1, and on task demo in this repository` }; } async function check({ cli, log, attended = false }) { @@ -360,7 +366,10 @@ async function checkIn({ root, cli, api, log, attended }) { } const byTask = JSON.parse(await aidd("aidd telemetry report --axis task", "report", "--json", "--axis", "task")); const byTicket = JSON.parse(await aidd("aidd telemetry report --axis ticket", "report", "--json", "--axis", "ticket")); - let verdict = judge({ billed: api.billed, byTask, byTicket }); + const here = JSON.parse( + await aidd("aidd telemetry report --task demo --repository .", "report", "--json", "--task", "demo", "--repository", ".") + ); + let verdict = judge({ billed: api.billed, byTask, byTicket, here }); if (verdict.code === 0 && session !== null) { const person = judgeAttended(session); verdict = person.code === 0 ? { code: 0, message: `${verdict.message}; ${person.message}` } : person;