Skip to content
2 changes: 2 additions & 0 deletions aidd_docs/product/usage-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ None observable by the CLI.
| Calls on a name used before, after a copy, rename or recreation and before it is declared again | the earlier generation's declaration still answers in the report, while `status` and the gate say undeclared: a branch with no declaration takes no snapshot, so nothing marks the new generation. Only calls the gate did not hold (unattended runs, IDE extensions) can land there | declare right after reusing a name |
| Reflog entry of the rename or copy expired before B's first declaration | B's start is its oldest remaining entry; on a name used before, an older start can let the earlier B's declaration speak for later calls | declare soon after a rename or copy |
| Branch snapshots recorded before stamping (a copied branch, a hand-set task with a `declared_at`) | keep attributing: attribution reads snapshots, `branches.jsonl` is append-only | - |
| An ingest holding the ledger lock more than 10 minutes | another run clears the lock as stale while its holder still runs, and both write the ledger. An ingest was measured at 4.5 s for 250k records over 50 roots and 20.8 s for 1M over 200; one machine's whole history is about 267k usage lines | - |
| Records stored before reasoning and an advisor's one-hour write were read | keep `reasoning` and the advisor `cache_write_1h` at `null`: a transcript line already read is not read again | - |

## Never leaves the machine

Expand Down
6 changes: 6 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ function resolveCommandPath(actionCommand: Command): string {
return parts.join(" ");
}

// A reader that stops reading (`aidd … | head -1`) closes stdout under the run: what is left to
// print has nobody to read it, so the run goes on without it and exits on its own outcome.
process.stdout.on("error", (error: NodeJS.ErrnoException) => {
if (error.code !== "EPIPE") throw error;
});

const cliArgs = process.argv.slice(2);

if (cliArgs.length === 0 && process.stdout.isTTY) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ export function readClaudeUsageLine(line: string): ClaudeUsageLine {
tool_version: text(root.version),
session_id: sessionId,
at,
reasoning: null,
cwd: text(root.cwd),
git_branch: text(root.gitBranch),
} as const;
Expand All @@ -139,6 +138,7 @@ export function readClaudeUsageLine(line: string): ClaudeUsageLine {
cache_read: expected(usage, "cache_read_input_tokens", "usage", notes),
cache_write: writes.total,
cache_write_1h: writes.oneHour,
reasoning: counter(asPlainObject(usage.output_tokens_details)?.thinking_tokens),
};

const advisors = iterations
Expand All @@ -153,7 +153,8 @@ export function readClaudeUsageLine(line: string): ClaudeUsageLine {
output: expected(advisor, "output_tokens", "advisor", notes),
cache_read: expected(advisor, "cache_read_input_tokens", "advisor", notes),
cache_write: expected(advisor, "cache_creation_input_tokens", "advisor", notes),
cache_write_1h: null,
cache_write_1h: counter(asPlainObject(advisor.cache_creation)?.ephemeral_1h_input_tokens),
reasoning: null,
})
);

Expand Down
13 changes: 11 additions & 2 deletions cli/src/contexts/telemetry/domain/report/period.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export type PeriodOutcome =

const DAY = /^\d{4}-\d{2}-\d{2}$/;

/** More days than lie between 0000-01-01 and 9999-12-31: a shift past it never names a
* four-digit day, and one far enough past it leaves what a date can hold. */
const MAX_SHIFT = 3_652_425;

/** The UTC day of an instant, `YYYY-MM-DD`. */
export function dayOf(at: string): string {
return new Date(at).toISOString().slice(0, 10);
Expand All @@ -22,8 +26,10 @@ export function inPeriod(at: string, period: Period): boolean {
return (period.from === null || day >= period.from) && (period.to === null || day <= period.to);
}

/** The day `days` after a `YYYY-MM-DD` day, or `null` past what four digits can name. */
/** The day `days` after a `YYYY-MM-DD` day, or `null` past what four digits can name, the
* end of what a date can hold included. */
function shift(day: string, days: number): string | null {
if (Math.abs(days) > MAX_SHIFT) return null;
const shifted = shiftDay(day, days);
return DAY.test(shifted) ? shifted : null;
}
Expand Down Expand Up @@ -65,7 +71,10 @@ export function periodOf(
const days = /^[1-9]\d*$/.test(asked.days) ? Number(asked.days) : 0;
if (days === 0) return { ok: false, message: "--days takes a positive whole number." };
const today = dayOf(now.toISOString());
const first = shiftDay(today, 1 - days);
const first = shift(today, 1 - days);
if (first === null) {
return { ok: false, message: "--days reaches before the year 0000: give fewer days." };
}
return { ok: true, period: { from: first, to: today } };
}
for (const [flag, value] of [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ const REFUSALS = {
"An identity is set, so this clone cannot be measured anonymously. Remove it with `aidd telemetry identity --off`, then run this again. Nothing was changed.",
} as const;

function plural(count: number, noun: string): string {
return `${count} ${noun}${count === 1 ? "" : "s"}`;
function plural(count: number, noun: string, nouns = `${noun}s`): string {
return `${count} ${count === 1 ? noun : nouns}`;
}

function identityLine({ personId, previous }: OnIdentity): string {
Expand Down Expand Up @@ -144,7 +144,7 @@ function warnSkipped(output: CLIOutput, plan: ForgetPlan): void {
}
if (plan.unlocated > 0) {
output.warn(
`${plural(plan.unlocated, "repository")} declared a task and could not be located.`
`${plural(plan.unlocated, "repository", "repositories")} declared a task and could not be located.`
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@ const call = fc
{ weight: 1, arbitrary: fc.integer({ min: -10 * DAY_MS, max: 10 * DAY_MS }) }
),
output: fc.nat(5),
// Observed on most real calls: the thinking part of `output`, so never above it.
reasoning: fc.option(fc.nat(5), { nil: null }),
session: fc.constantFrom("s-1", "s-2"),
})
.map(
({ keyIndex, tool, offset, output, session }): StoredUsage => ({
({ keyIndex, tool, offset, output, reasoning, session }): StoredUsage => ({
tool,
tool_version: "1.0.0",
key: `k${keyIndex}`,
Expand All @@ -63,7 +65,7 @@ const call = fc
cache_read: 0,
cache_write: 0,
cache_write_1h: null,
reasoning: null,
reasoning: reasoning === null ? null : Math.min(reasoning, output),
cwd: null,
git_branch: null,
repository_id: "repo-1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,27 @@ describe("one transcript line to usage records", () => {
});
});

it("takes the call's reasoning from its thinking tokens, and none for an advisor", () => {
const usage = {
...USAGE,
output_tokens_details: { thinking_tokens: 120 },
iterations: [{ type: "advisor_message", model: "m", input_tokens: 1, output_tokens: 2 }],
};
const { records } = readClaudeUsageLine(line({}, { usage }));
expect(records.map((r) => r.reasoning)).toEqual([120, null]);
});

it("takes an advisor iteration's one-hour write from its own split", () => {
const advisor = {
type: "advisor_message",
model: "m",
cache_creation_input_tokens: 7,
cache_creation: { ephemeral_5m_input_tokens: 4, ephemeral_1h_input_tokens: 3 },
};
const usage = { ...USAGE, iterations: [advisor] };
expect(readClaudeUsageLine(line({}, { usage })).records[1]?.cache_write_1h).toBe(3);
});

it("reports a usage line of an unexpected shape and guesses no record", () => {
const noId = line({}, { id: undefined });
const outcome = readClaudeUsageLine(noId);
Expand Down
13 changes: 13 additions & 0 deletions cli/tests/contexts/telemetry/domain/report/period.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ describe("the period of a report", () => {
}
});

it("reaches back to the year 0000 and refuses a count that goes further, saying so", () => {
expect(periodOf({ days: "740264" }, NOW)).toEqual({
ok: true,
period: { from: "0000-01-01", to: "2026-10-09" },
});
for (const days of ["740265", "99999999", "99999999999", "1000000000000000000000"]) {
expect(periodOf({ days }, NOW)).toEqual({
ok: false,
message: "--days reaches before the year 0000: give fewer days.",
});
}
});

it("refuses a date that is not a calendar day, naming the flag", () => {
for (const from of [
"2026-13-01",
Expand Down
33 changes: 33 additions & 0 deletions cli/tests/e2e/telemetry-report-closed-output.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { spawn } from "node:child_process";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { cliPath, createTestEnv, sandboxedEnv } from "./helpers.js";

let env: Awaited<ReturnType<typeof createTestEnv>>;

beforeEach(async () => {
env = await createTestEnv("telemetry-closed-output");
});
afterEach(async () => {
await env.cleanup();
});

describe("aidd telemetry report into a reader that stops reading", () => {
it("ends quietly, as `| head -1` does to it", async () => {
const child = spawn(process.execPath, [cliPath(), "telemetry", "report"], {
cwd: env.projectDir,
env: sandboxedEnv(env.fakeHome, {
AIDD_TELEMETRY_DIR: join(env.tempDir, "telemetry"),
CLAUDE_CONFIG_DIR: join(env.tempDir, "claude"),
}),
});
child.stdout.destroy();
let stderr = "";
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const code = await new Promise<number | null>((done) => child.on("close", done));
expect(stderr).toBe("");
expect(code).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,11 @@ describe("printForgetResult", () => {
]);
});

it("counts several repositories it could not locate in plain English", () => {
const output = forgotten({ status: "preview", plan: { ...PLAN, unlocated: 2 } });
expect(output.at("warn")).toContain("2 repositories declared a task and could not be located.");
});

it("still names a repository it skipped when nothing else is kept", () => {
const output = forgotten({
status: "preview",
Expand Down
4 changes: 3 additions & 1 deletion plugins/aidd-telemetry/skills/01-usage/actions/answer.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ The figures the report holds for the question, each counter apart, followed by e
- `totals.total.unknown_records` above `0`: the same for the total;
- a row of kind `unattributed`: its `reason`, with its meaning from the reference;
- a row of kind `absent`: that the axis had no value for those calls;
- `run.unrecognised_shapes`, `run.skipped_ledger_lines`, any `run.not_stored` entry or any `coverage.left_out` entry above `0`: that some lines were read and left out, with the counts;
- `run.unrecognised_shapes`, `run.skipped_ledger_lines` or any `run.not_stored` entry above `0`: that some lines were read and left out, with the counts;
- any `coverage.left_out` entry above `0`: that many calls or lines were left out, from any day, not only the period;
- `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 `run.oldest_transcript_at`, say that Claude Code may have removed older session files, so earlier figures can be incomplete.
Expand All @@ -35,6 +36,7 @@ The figures the report holds for the question, each counter apart, followed by e
| --- | --- |
| A report with `unknown_records` above `0` | The answer says the figure is a lower bound, and never shows `0` for it |
| A report with a `no-binding` row | The answer names the row and what it means |
| A `--days 7` report with `coverage.left_out.requestless` above `0` | The answer gives the count and says it is not bounded to the period |
| A `refused` envelope | No figure is given |
| An envelope of `version` `1` | No figure; the answer says to update the CLI so it prints version `2` |
| An envelope of `version` `3` | No figure; the answer says to update the `aidd-telemetry` plugin so it reads that version |
Expand Down
2 changes: 2 additions & 0 deletions plugins/aidd-telemetry/skills/01-usage/actions/frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ The command to run, and the period, axis and filters it stands for, said back to
- `--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.
- Reasoning or thinking tokens have no figure of their own: they are counted in `output`. Say so.
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
Expand All @@ -54,6 +55,7 @@ The command to run, and the period, axis and filters it stands for, said back to
| "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 |
| "How many thinking tokens this week?" | No figure apart; the answer says reasoning is counted in `output` |
| "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` |
Expand Down
Loading