From 9a7880816718f87f82704c1f563bded8b5ab12cf Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 9 Oct 2026 14:31:06 +0200 Subject: [PATCH 01/73] docs(telemetry): frame deterministic token measurement, release 1 The contract for replacing the previous telemetry: every billed Claude Code call counted once, broken down by person, session, model, period, repository, task and ticket, the task coming only from a declaration. Carries the measurements, the common usage record and the prototype readers with their mutation-proved fixtures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp AIDD-Session-Id: 2c21d903-3a7e-47ac-83f8-d8b7ae3aa579 --- .../context/prototype/check.py | 82 ++++++++++++++ .../prototype/fixtures/claude-main.jsonl | 4 + .../fixtures/claude-resumed-copy.jsonl | 1 + .../prototype/fixtures/codex-rollout.jsonl | 6 + .../fixtures/subagents/agent-x.jsonl | 1 + .../context/target.md | 87 +++++++++++++++ .../context/usage-record-contract.md | 45 ++++++++ .../2026_10_09_deterministic-tokens/spec.md | 104 ++++++++++++++++++ 8 files changed, 330 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/check.py create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-main.jsonl create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-resumed-copy.jsonl create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/codex-rollout.jsonl create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/subagents/agent-x.jsonl create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/usage-record-contract.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/check.py b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/check.py new file mode 100644 index 000000000..ab3534f2b --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/check.py @@ -0,0 +1,82 @@ +"""Prototype: two tool readers -> one usage-record contract. Asserts on synthetic fixtures.""" +import json, glob, os, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +FX = os.path.join(HERE, "fixtures") + +def rec(tool, key, session, agent, at, model, inp, out, cr, cw, cw1h, reasoning, cwd, branch, version): + return dict(tool=tool, key=key, session_id=session, agent=agent, at=at, model=model, + input=inp, output=out, cache_read=cr, cache_write=cw, cache_write_1h=cw1h, + reasoning=reasoning, cwd=cwd, git_branch=branch, tool_version=version) + +def claude(files): + best = {} + for f in files: + for raw in open(f): + r = json.loads(raw); m = r.get("message") or {}; u = m.get("usage") + if r.get("type") != "assistant" or not isinstance(u, dict) or m.get("model") == "": + continue + key = f"{m['id']}:{r['requestId']}" if r.get("requestId") else f"{m['id']}:{r['sessionId']}:{r['timestamp']}" + total = sum(u.get(k) or 0 for k in ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")) + if key not in best or total > best[key][0]: + best[key] = (total, r) + out = [] + for key, (_, r) in best.items(): + m = r["message"]; u = m["usage"]; its = u.get("iterations") or [] + msgs = [i for i in its if i.get("type") == "message"] or [u] + cw = sum(i.get("cache_creation_input_tokens") or 0 for i in msgs) + splits = [i.get("cache_creation") for i in msgs] + cw1h = sum(s.get("ephemeral_1h_input_tokens", 0) for s in splits) if all(isinstance(s, dict) for s in splits) else None + agent = "subagent" if r.get("isSidechain") or r.get("agentId") else "main" + base = (r["sessionId"], None, r["timestamp"]) + out.append(rec("claude-code", key, r["sessionId"], agent, r["timestamp"], m["model"], + u.get("input_tokens"), u.get("output_tokens"), u.get("cache_read_input_tokens"), + cw, cw1h, None, r.get("cwd"), r.get("gitBranch"), r.get("version"))) + for n, a in enumerate(i for i in its if i.get("type") == "advisor_message"): + out.append(rec("claude-code", f"{key}#advisor{n}", r["sessionId"], "advisor", r["timestamp"], a.get("model"), + a.get("input_tokens"), a.get("output_tokens"), a.get("cache_read_input_tokens"), + a.get("cache_creation_input_tokens"), None, None, r.get("cwd"), r.get("gitBranch"), r.get("version"))) + return out + +def codex(path): + out = []; session = version = cwd = branch = model = None; last_total = None + for raw in open(path): + r = json.loads(raw); p = r.get("payload") or {} + if r["type"] == "session_meta": + session, version, cwd = p["id"], p.get("cli_version"), p.get("cwd") + branch = (p.get("git") or {}).get("branch") + elif r["type"] == "turn_context": + model, cwd = p.get("model"), p.get("cwd", cwd) + elif p.get("type") == "token_count" and p.get("info"): + total = p["info"]["total_token_usage"]["total_tokens"] + if total == last_total: # verbatim re-emission, not a new call + continue + last_total = total; l = p["info"]["last_token_usage"] + out.append(rec("codex", f"{session}:{total}", session, "main", r["timestamp"], model, + l["input_tokens"] - l["cached_input_tokens"], l["output_tokens"], l["cached_input_tokens"], + l.get("cache_write_input_tokens"), None, l.get("reasoning_output_tokens"), cwd, branch, version)) + return out + +c = claude(sorted(glob.glob(FX + "/claude-*.jsonl")) + glob.glob(FX + "/subagents/*.jsonl")) +x = codex(FX + "/codex-rollout.jsonl") +by = {r["key"]: r for r in c + x} +expect = { + "msg_A:req_A": dict(agent="main", input=3, output=250, cache_read=1000, cache_write=200, cache_write_1h=200), + "msg_B:req_B": dict(agent="main", input=4, output=300, cache_read=5000, cache_write=2283, cache_write_1h=2283), + "msg_B:req_B#advisor0": dict(agent="advisor", model="claude-fable-5-1", input=90000, output=700, cache_write_1h=None), + "msg_C:req_C": dict(agent="subagent", model="claude-sonnet-5-5", input=5, cache_write=60, cache_write_1h=0), + "cx-1:1050": dict(input=200, cache_read=800, output=50, reasoning=20, cache_write_1h=None), + "cx-1:2630": dict(input=500, cache_read=1000, output=80, reasoning=20, model="gpt-5.5-codex"), +} +fail = 0 +keys = [r["key"] for r in c + x] +if len(keys) != len(set(keys)): + print("DUPLICATE KEYS"); fail += 1 +if set(by) != set(expect): + print("KEYS", sorted(set(by) ^ set(expect))); fail += 1 +for k, e in expect.items(): + for f, v in e.items(): + if by.get(k, {}).get(f, "MISSING") != v: + print(f"FAIL {k}.{f} = {by.get(k, {}).get(f, 'MISSING')} expected {v}"); fail += 1 +print(f"{len(c)} claude + {len(x)} codex records, {fail} failures") +sys.exit(1 if fail else 0) diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-main.jsonl b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-main.jsonl new file mode 100644 index 000000000..69c2903d4 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-main.jsonl @@ -0,0 +1,4 @@ +{"type":"assistant","sessionId":"s-1","requestId":"req_A","timestamp":"2026-10-07T10:00:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":10,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} +{"type":"assistant","sessionId":"s-1","requestId":"req_A","timestamp":"2026-10-07T10:00:01.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":250,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} +{"type":"assistant","sessionId":"s-1","requestId":"req_B","timestamp":"2026-10-07T10:01:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a/src","gitBranch":"feat/a","message":{"id":"msg_B","model":"claude-opus-5-5","usage":{"input_tokens":4,"output_tokens":300,"cache_read_input_tokens":5000,"cache_creation_input_tokens":2283,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1436},"iterations":[{"type":"message","input_tokens":2,"output_tokens":100,"cache_read_input_tokens":2500,"cache_creation_input_tokens":1436,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1436}},{"type":"advisor_message","model":"claude-fable-5-1","input_tokens":90000,"output_tokens":700,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},{"type":"message","input_tokens":2,"output_tokens":200,"cache_read_input_tokens":2500,"cache_creation_input_tokens":847,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":847}}]}}} +{"type":"assistant","sessionId":"s-1","requestId":"req_S","timestamp":"2026-10-07T10:02:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_S","model":"","usage":{"input_tokens":0,"output_tokens":0}}} diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-resumed-copy.jsonl b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-resumed-copy.jsonl new file mode 100644 index 000000000..b13b25955 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/claude-resumed-copy.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"s-2","requestId":"req_A","timestamp":"2026-10-07T10:00:01.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":10,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/codex-rollout.jsonl b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/codex-rollout.jsonl new file mode 100644 index 000000000..3e93a17d6 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/codex-rollout.jsonl @@ -0,0 +1,6 @@ +{"type":"session_meta","timestamp":"2026-10-07T09:00:00.000Z","payload":{"id":"cx-1","cwd":"/work/repo-wt-b","cli_version":"0.150.0","git":{"branch":"fix/b"}}} +{"type":"turn_context","timestamp":"2026-10-07T09:00:01.000Z","payload":{"turn_id":"t-1","model":"gpt-5.5-codex","cwd":"/work/repo-wt-b"}} +{"type":"event_msg","timestamp":"2026-10-07T09:00:05.000Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":800,"cache_write_input_tokens":0,"output_tokens":50,"reasoning_output_tokens":20,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":800,"cache_write_input_tokens":0,"output_tokens":50,"reasoning_output_tokens":20,"total_tokens":1050}}}} +{"type":"event_msg","timestamp":"2026-10-07T09:00:09.000Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2500,"cached_input_tokens":1800,"cache_write_input_tokens":0,"output_tokens":130,"reasoning_output_tokens":40,"total_tokens":2630},"last_token_usage":{"input_tokens":1500,"cached_input_tokens":1000,"cache_write_input_tokens":0,"output_tokens":80,"reasoning_output_tokens":20,"total_tokens":1580}}}} +{"type":"event_msg","timestamp":"2026-10-07T09:00:09.500Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2500,"cached_input_tokens":1800,"cache_write_input_tokens":0,"output_tokens":130,"reasoning_output_tokens":40,"total_tokens":2630},"last_token_usage":{"input_tokens":1500,"cached_input_tokens":1000,"cache_write_input_tokens":0,"output_tokens":80,"reasoning_output_tokens":20,"total_tokens":1580}}}} +{"type":"event_msg","timestamp":"2026-10-07T09:00:10.000Z","payload":{"type":"token_count","info":null}} diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/subagents/agent-x.jsonl b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/subagents/agent-x.jsonl new file mode 100644 index 000000000..86bd05984 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/prototype/fixtures/subagents/agent-x.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"s-1","agentId":"x","isSidechain":true,"requestId":"req_C","timestamp":"2026-10-07T10:03:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_C","model":"claude-sonnet-5-5","usage":{"input_tokens":5,"output_tokens":40,"cache_read_input_tokens":300,"cache_creation_input_tokens":60,"cache_creation":{"ephemeral_5m_input_tokens":60,"ephemeral_1h_input_tokens":0}}}} diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md new file mode 100644 index 000000000..fd4ad4ff1 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md @@ -0,0 +1,87 @@ +# Telemetry target, release 1 (v3, after shadow areas and four challenges) + +> Research record. Where it differs from [`../spec.md`](../spec.md), the spec wins: release 1 +> sends nothing (point 8 is out), the previous telemetry is removed rather than reused, and the +> command's final name is set by the plan. + +Goal: Claude Code only. Deterministic tokens per person, session, model, day, repository; +tokens per task and ticket. Push to Gouvernail. Ship fast and clean. + +## Release 1 + +1. **Reader.** `/projects/**`, recursive (subagents, + workflows, `.jsonl.superseded-*` set-asides). Byte offset per file; a file that shrank or + changed identity is re-read whole; an unterminated last line waits. Key + `message.id:requestId` (requestless: `message.id:sessionId:timestamp`), global fold: max + total, tie → earliest `at`, then lowest `session_id`. Advisor iterations are records; + cache writes summed over message iterations; `` dropped. Root resolved per + line (subagents run in their own worktrees). +2. **Root and repository at ingest.** realpath + case-fold → `git rev-parse` → root; + repository id = hash of `host/owner/repo` from `origin`, else the root commit SHA. + cwd gone (16 % of lines on backfill) → `root-unresolved`, not `outside-repo`. + Project switch off or `AIDD_TELEMETRY=0` → not ingested at all. +3. **Ledger** in its own subdirectory of the telemetry dir (the v1 sink prunes `*.jsonl` + by count there), relocated by `AIDD_TELEMETRY_DIR`; monthly partitions; newline guard; + lock with pid and age, stale lock cleared. `forget` covers it. +4. **Ingest**: every `aidd telemetry` command; `SessionStart` async catch-up (also fires on + resume, clear, compact: idempotent). No ingest in `Stop` (hooks are skipped under + `--bare`, safe mode, managed-only hooks, and blocked in cloud sessions). Setup offers + `cleanupPeriodDays: 3650`, reports a managed override or a paused sweep. Reports state + coverage. +5. **Binding: asked once per working branch, remembered, all inside the plugin.** + - Store: `git config branch..aiddTask` (+ ticket, created-at from reflog). Follows + renames, dies with the branch. The branch name is never parsed. + - Session override: `aidd task start` run inside a session (`CLAUDE_CODE_SESSION_ID`, + Codex `CODEX_THREAD_ID`) binds that session; it beats the branch. Measured: a `!` + command sees `CLAUDE_CODE_SESSION_ID` and `ATTENDED=1`, and does not fire + `UserPromptSubmit`, so answering the block is never itself blocked. `/clear` starts a + new session id and the payload carries no previous id, but the process survives + (measured: `CLAUDE_PID` and hook parent identical across startup + 2 `/clear`). + `SessionStart` records `(CLAUDE_PID, process start time) → session`; on + `source: clear` it copies the predecessor's binding to the new id, tagged + `carried: clear from `. `resume` uses the resumed session's own binding; `fork` + and `startup` carry nothing. Start time guards PID reuse. + - A carried binding is provisional: it is announced (`systemMessage`, measured visible: + "SessionStart:clear says: …") with the command to change it, and the first explicit + `aidd task start` in that session replaces it for the whole session, retroactively. + An explicit binding in a session that already had one applies from its timestamp. + The prompt is never read to guess the need. + - When to ask (pure code, `UserPromptSubmit`, every prompt, cheap): telemetry on for the + project AND `CLAUDE_CODE_SESSION_ATTENDED=1` AND branch is neither the default + (`origin/HEAD`) nor detached AND neither branch nor session is bound → block, reason + shown to the human: `! aidd task start [--ticket X]` or `--none`. Re-checked + each prompt, so switching to an unbound branch asks again. Blocking before the model + call means no token is spent unattributed. + - Unattended (`ATTENDED=0` with `ENTRYPOINT=sdk-cli` under `-p`, measured; + `CLAUDE_CODE_CHILD_SESSION=1` in both modes, so not a signal) or flag absent: never + block (a blocked `-p` exits 0 having done nothing, measured). Bound branch → bound + automatically; unbound → `no-binding`, re-attributed once the branch is bound. + - Subagent and workflow lines carry the parent `sessionId` → inherit its binding. + - Skills that know the ticket call the command first, so the block never shows. + - No launcher, orchestrator, env var or branch convention is required or read. +6. **Attribution** (one pure local function, decided: the command is the only source): + session binding → + `unattributed` with reason `outside-repo | root-unresolved | no-binding | declared-none`. +7. **Report axes**: person (v1 opt-in `person_id`), session, model, day, repository, task, + ticket. Four counters always separate. +8. **Push**: opted-in records, attributed locally; no cwd, root, or branch names. Upsert by + `(person, tool, key)`; a re-bind re-pushes affected keys. + +## Deferred (they caused most of the silent-misattribution findings) + +Root-scoped bindings and their adoption/`ambiguous` rule; cross-machine binding sync; +"session seen" marker; ingest inside `Stop`; cloud sessions (need managed settings). + +## Verified (sandbox, Claude Code 2.1.295) + +- Hooks fire in interactive and `-p`; `ATTENDED` 1 vs 0; `!` carries the session id; + `/clear` = new id, no link in the payload. +- Codex `UserPromptSubmit` can block (docs: `decision: block` or exit 2). Its unattended + signal is not yet known: Codex stays out of release 1. +- `CLAUDE_CODE_SESSION_ATTENDED` is undocumented: a contract test pins its two values, and + the block also requires `CLAUDE_CODE_ENTRYPOINT=cli` (two independent signals must agree). + +## Oracles + +Fixture + mutation per rule (contract/check.py: 6/6 killed); 16 counting pitfalls; ccusage +diff; OTel `api_request` by `request_id`; Analytics API daily totals. diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/usage-record-contract.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/usage-record-contract.md new file mode 100644 index 000000000..d8566a20b --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/usage-record-contract.md @@ -0,0 +1,45 @@ +# Usage record contract (draft v0) + +One record per billed model call. Facts only: no cost, person, task, ticket or step. +Those are derived downstream, once, for every tool. + +| Field | Type | Meaning | +| --- | --- | --- | +| `tool` | string | `claude-code`, `codex`, … | +| `tool_version` | string \| null | version that wrote the source line | +| `key` | string | stable across re-reads, unique within `tool`; storage is idempotent on `(tool, key)` | +| `session_id` | string | the tool's own session id | +| `agent` | `main` \| `subagent` \| `advisor` | who made the call | +| `at` | ISO-8601 UTC | when the call was recorded | +| `model` | string \| null | model that billed this call | +| `input` | int \| null | input tokens **excluding** any cache read or write | +| `output` | int \| null | output tokens **including** reasoning | +| `cache_read` | int \| null | input tokens served from cache | +| `cache_write` | int \| null | input tokens written to cache, all TTLs | +| `cache_write_1h` | int \| null | of which 1-hour TTL; `null` when the tool does not say | +| `reasoning` | int \| null | of which reasoning, a subset of `output`; `null` when the tool does not say | +| `cwd` | string \| null | raw working directory of that line, joined to a worktree downstream; local only | +| `git_branch` | string \| null | raw, data only, never an attribution source | + +Rules: an unknown value is `null`, never `0`. A shape the reader does not recognise is reported, never guessed. + +## Per-tool normalisation (proved on fixtures, each by a mutation) + +Claude Code +- key `message.id:requestId`; requestless lines `message.id:sessionId:timestamp`; keep the row with the largest total (streaming snapshots, resumed copies). +- drop ``. +- `cache_write` and `cache_write_1h` summed over `iterations[type=message]`; the top-level `cache_creation` split reflects only the first iteration (measured, 86 requests on 2026-10-07; ccusage 20.0.26 under-counts by it). +- each `iterations[type=advisor_message]` is its own record, `agent: advisor`, its own model; top-level usage excludes it. +- `isSidechain` or `agentId` → `subagent`. + +Codex +- key `session_meta.id:total_token_usage.total_tokens` (monotonic: 3,765/3,765 on 50 real rollouts). +- skip a `token_count` whose cumulative total did not move (verbatim re-emission, 3.5 % of events on 200 rollouts). +- `input = last.input_tokens − last.cached_input_tokens` (OpenAI input is inclusive of cache). +- model from the preceding `turn_context`. + +## Open + +- Codex forks and sub-agents: `session_meta` carries `forked_from_id`, `parent_thread_id`, `subagent_history_start_ordinal`. Does a forked rollout replay parent `token_count` events? If so the key above double-counts across sessions. +- Claude `cache_write_1h` across iterations when a later iteration writes 1h: fixture asserts the sum; real occurrence not yet measured. +- `cwd` per line for Codex: `turn_context.cwd` assumed to follow directory changes; not measured. diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md new file mode 100644 index 000000000..ea16cd875 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md @@ -0,0 +1,104 @@ +--- +status: framed +branch: feat/telemetry-deterministic-tokens +--- + +# Deterministic token measurement, release 1 + +## Target + +On a machine running Claude Code, every billed model call is counted exactly once and can be +broken down by person, session, model, period, repository, task and ticket, the task and ticket +coming only from what a person declared, with the previous telemetry removed entirely. + +## Hard constraints + +- The previous telemetry is removed, not adapted: its journal, its hooks, its records and + their stored format, its report envelope, its commands and checks, the backlog link a task + folder declares, the step markers skills emit, and the commit trailer. Nothing of it is read, + migrated or kept compatible. +- Removal never breaks a user's repository. Opting in with the new version removes what the + previous one left in that repository (the commit hook line together with its script, the run + journal and its ignore entry); forgetting also removes the previous version's stored + measurement and identity. A commit hook line pointing at a missing script fails every commit + (measured), so the line and its script are only ever removed together. +- Counting is deterministic: the same transcripts give the same totals on every run, in any + order, read once or many times. Each billed call is counted once, whichever copies of it the + tool wrote (streaming snapshots, resumed sessions, sub-agents, advisor calls). +- Input, output, cache read and cache write are always reported apart. An unknown value is + unknown, never zero. A shape the reader does not recognise is reported, never guessed. +- One per-tool reader turns a tool's own files into a common usage record; everything after + it (storage, attribution, report, export) is written once for every tool. +- A task and its ticket are known only from an explicit declaration by a command. No branch + name, folder name, commit message or prompt text is ever parsed to guess one. +- The declaration is asked for at most once per working branch, only of a person present, and + before any token of that work is spent, on every surface where a person can be present + (terminal, IDE extension, desktop app), including those without a shell mode. Answering is + never itself blocked and never reaches the model. A session with nobody present, or whose + presence is unknown, is never blocked. +- A session started by `/clear` keeps the task of the session it replaced, says so to the + person, and a later declaration in it re-attributes the whole session. +- Work with no declared task is its own row, with the reason it has none, and is attributed + retroactively once its branch is declared. +- Every breakdown of a period sums to the same total. +- A project or person that has not opted in is untouched: nothing read, nothing asked, nothing + blocked. Without the plugin, nothing changes at all. +- Data stays on the machine: release 1 sends nothing anywhere. +- The person identity is a fresh, separate opt-in, located with the rest of the person's aidd + configuration; the previous identity file is not carried over. +- Public repository: no real session id, prompt, machine path or personal name in any fixture. +- Every rule ships with the test that failed first and the mutation that proves the test. +- All gates green, Windows included. + +## Non-goals + +- Codex, Copilot, Cursor, opencode and Kilo measurement. Their measurement disappears with the + previous telemetry until their own reader lands; the common record already admits them. +- Amounts in currency: the destination owns the price table (decision record of 2026-09-01). +- Sending anything to the hosted destination (Gouvernail), or defining its record: designed + later, with the destination. +- Aggregating across machines or people: the destination's job. +- Cloud sessions, whose transcripts are not on the machine. +- Step, skill, flow, prompt and produced-file breakdowns. +- Guessing the task from the work itself. + +## Done-when + +- No trace of the previous telemetry remains in the shipped product, its tests or its docs, + except completed task folders kept as history, and pending ones are marked superseded. +- A user who had the previous version enabled can still commit before and after opting in + again; after opting in, nothing the previous version left remains in that repository, and + after forgetting, nothing remains in their profile either. +- On the shared fixtures, every counting rule gives the expected per-call record, and breaking + any one rule turns its own test red. +- On real local history, the release's daily totals equal an independent reference count of + the same transcripts, the known reference under-count of cache writes aside and explained. +- A person present on an unbound working branch, in the terminal or an IDE, cannot get a model + answer until they declare a task or declare none, and the declaration itself costs no model + call; the default branch and a detached HEAD never ask. +- A headless run on the same branch completes unblocked and its tokens land on the branch's + task, or on the unattributed row until the branch is declared, then on the task. +- After `/clear`, the new session's tokens land on the carried task, and a declaration made in + it moves all of them. +- A report for a period shows tokens per person, session, model, day, repository, task and + ticket, four counters apart, every axis summing to the same total, unattributed work on its + own row with its reason. +- A project that never opted in shows no prompt, no block, and nothing stored. + +## Open Questions + +None. + +## Stakeholders + +- Decider: the maintainer who framed release 1. +- Owner: the aidd-telemetry plugin and the `aidd` CLI. +- Consumer: developers using Claude Code with the plugin; the Gouvernail destination. + +## Context + +- Measurements, verified behaviours and the target shape: [`context/target.md`](context/target.md). +- The common usage record and per-tool normalisation: [`context/usage-record-contract.md`](context/usage-record-contract.md). +- Prototype readers and their mutation-proved fixtures: [`context/prototype/`](context/prototype/). +- Decision records kept: [hosted destination](../../../memory/internal/decisions/measurement-may-reach-a-hosted-destination.md), + [price table](../../../memory/internal/decisions/a-price-table-is-the-destination-s.md). From 6992ffc33057ba59ce5dd4daa46305a7336ad136 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 9 Oct 2026 18:28:06 +0200 Subject: [PATCH 02/73] docs(telemetry): plan deterministic token measurement in nine phases Removal of the previous telemetry first, then count, store, declare, ask, attribute and report, opt-in and cleanup, and the end-to-end proof. Pins the consent key, the binding snapshots, the hook guards and a mutation per rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp AIDD-Session-Id: 2c21d903-3a7e-47ac-83f8-d8b7ae3aa579 --- .../phase-1.md | 93 +++++++++++++ .../phase-2.md | 102 ++++++++++++++ .../phase-3.md | 93 +++++++++++++ .../phase-4.md | 102 ++++++++++++++ .../phase-5.md | 89 +++++++++++++ .../phase-6.md | 126 ++++++++++++++++++ .../phase-7.md | 92 +++++++++++++ .../phase-8.md | 85 ++++++++++++ .../phase-9.md | 85 ++++++++++++ .../2026_10_09_deterministic-tokens/plan.md | 55 ++++++++ .../2026_10_09_deterministic-tokens/spec.md | 8 +- 11 files changed, 926 insertions(+), 4 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-2.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-3.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-4.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-5.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-6.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-7.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-8.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-9.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/plan.md diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-1.md new file mode 100644 index 000000000..f7f6a9844 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-1.md @@ -0,0 +1,93 @@ +--- +status: pending +--- + + + +# Instruction: Remove the previous telemetry from the CLI + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/ +│ ├── cli.ts ✏️ no telemetry registration (re-added in phase 4) +│ ├── contexts/telemetry/ ❌ whole directory +│ ├── contexts/tools/domain/contracts.ts ✏️ drop telemetryLocalRead, telemetryJournalHost, telemetryTaskAttributable +│ ├── contexts/tools/domain/registry.ts ✏️ drop journalHostToAiToolId +│ ├── contexts/tools/domain/profiles/*/profile.ts ✏️ drop the three telemetry fields +│ ├── contexts/tools/domain/profiles/claude/claude-transcript-location.ts ❌ +│ ├── contexts/tools/domain/profiles/codex/codex-transcript-location.ts ❌ +│ ├── kernel/measurement.ts ❌ +│ ├── kernel/errors.ts ✏️ drop telemetry-only errors (keep any still thrown elsewhere) +│ ├── kernel/paths.ts ✏️ drop RUNS_ENTRY/resolvedRunsDir unless framework still needs them +│ ├── kernel/reading/home-dir.ts ✏️ drop resolveAiddConfigDir (only caller deleted) +│ ├── runtime/git/git-adapter.ts ✏️ drop the commit-trailer delegate methods +│ ├── runtime/wiring/telemetry.ts ❌ +│ ├── runtime/wiring/framework.ts ✏️ no TelemetryDeps +│ ├── runtime/wiring/installed-plugins-from-manifest.ts ❌ if telemetry was its only caller +│ ├── presentation/commands/telemetry.ts ❌ +│ └── presentation/display/{telemetry-*,cost-report-*}.ts ❌ +├── tests/ ❌ every test of the above; ✏️ architecture allow-lists +├── biome.json ✏️ drop telemetry layer overrides +├── mutation-scopes.json, package.json ✏️ drop the telemetry mutation scope and script +├── scripts/check-bundle-size.mjs ✏️ untouched here: the budget is set once, in phase 9 +├── scripts/smoke-tools.sh ✏️ drop telemetry smoke steps +└── tests/golden/snapshots/help/surface.json ✏️ regenerated, no telemetry +``` + +## User Journey + +```mermaid +flowchart TD + A[Developer runs aidd --help] --> B[No telemetry command listed] + B --> C[Every other command behaves as before] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Build the CLI from the branch => binary built: 5: system + section Happy path + Run aidd --help => no telemetry entry, every other command listed as before: 5: cli + Run the full CLI suite => green with no telemetry test left: 5: cli + section Edge case - straggler + A file still imports a deleted module => typecheck and knip fail => the straggler is named: 1: cli +``` + +## Tasks to do + +### `1)` Delete the context and its wiring + +> Nothing under `contexts/telemetry` survives, and nothing imports it. + +1. Delete `cli/src/contexts/telemetry/`, `runtime/wiring/telemetry.ts`, `presentation/commands/telemetry.ts`, and the telemetry and cost-report display modules. +2. Remove the registration from `cli.ts` and `TelemetryDeps` from `runtime/wiring/framework.ts`. +3. Remove telemetry-only code living elsewhere: `kernel/measurement.ts`, the three profile fields and their contract, the transcript locations, `journalHostToAiToolId`, `resolveAiddConfigDir`, telemetry-only errors, the git adapter's trailer delegate methods. +4. Keep `kernel/paths.ts` `AIDD_DIR`/`AIDD_CONFIG_FILENAME`; keep `RUNS_ENTRY` only if `framework` still reads it, otherwise delete it and its gitignore entry producer. + +### `2)` Delete the tests and update the guards + +> The suites and architecture guards describe the CLI that remains. + +1. Delete every telemetry test, fixture and in-memory port double listed by `grep -ril telemetry cli/tests`. +2. Remove `telemetry` from `ALLOWED`, `PUBLIC_MODULES`, folder-size, orchestrator-deps, tool-addition-cost, catches-that-swallow and self-reentry baselines; drop the biome telemetry overrides. +3. Remove the telemetry mutation scope and script; regenerate the help golden with `UPDATE_HELP_GOLDEN=1`. +4. Drop telemetry steps from `scripts/smoke-tools.sh`. Leave the bundle budget alone. +5. Before deleting, copy into phase 8's notes every location the previous version wrote: the sink dir resolution (`AIDD_TELEMETRY_DIR`, `AIDD_USER_CONFIG_DIR`, `%APPDATA%\aidd\telemetry`, `~/.config/aidd/telemetry`), `identity.json` (`%APPDATA%\aidd` or `~/.config/aidd`), the trailer delegate name and hook line, `aidd_docs/runs` and `AIDD_RUNS_DIR`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `grep -ril telemetry cli/src` finds nothing but comments explaining the `.aidd/config.json` file `clean` keeps | +| 1 | `aidd --help` lists no `telemetry` command; `setup`, `plugin`, `clean`, `framework build` behave as before | +| 2 | typecheck, lint, arch, knip, jscpd, the three tiers and the build with its budget are green | +| 2 | No architecture baseline names a context or file that no longer exists | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-2.md new file mode 100644 index 000000000..2fd2a9c71 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-2.md @@ -0,0 +1,102 @@ +--- +status: pending +--- + + + +# Instruction: Remove the previous telemetry from the plugins, scripts, CI and docs + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── plugins/aidd-telemetry/ +│ ├── hooks/ ❌ journal.cjs, lib/, opencode-plugin.js, hooks.json (rebuilt in phase 6) +│ ├── skills/{00-init,01-cost,02-check}/ ❌ (rebuilt in phase 9) +│ ├── README.md ✏️ "being rebuilt" until phase 9 +│ └── CATALOG.md ✏️ regenerated +├── plugins/aidd-pm/skills/04-spec/ +│ ├── actions/01-build.md ✏️ no Declare step, no backlog-link test row +│ ├── references/backlog-link.md ❌ +│ └── assets/backlog-link-template.json ❌ +├── plugins/aidd-dev/skills/01-plan/ +│ ├── actions/04-plan.md ✏️ no backlog-link step or test +│ └── SKILL.md ✏️ no step-end marker +├── plugins/aidd-orchestrator/skills/ +│ ├── 01-sdlc/SKILL.md ✏️ no "Say when this orchestration is over" +│ ├── 01-sdlc/references/01-frame.md ✏️ no backlog-link paragraph +│ ├── 00-async-dev/SKILL.md ✏️ no step-end marker +│ └── 02-backlog/SKILL.md ✏️ no step-end marker +├── scripts/__tests__/aidd-telemetry-*.test.js, telemetry-*.test.js, a-backlog-link-*.test.js ❌ +├── scripts/__tests__/fixtures/ (telemetry-only fixtures) ❌ +├── scripts/lib/telemetry-reference-week.cjs, scripts/probe-identifier-join.cjs ❌ +├── lefthook.yml ✏️ no prepare-commit-msg trailer job, no prompts-doc job if it only served telemetry +├── .github/workflows/cli-ci.yml, ci.yml ✏️ no journal replay, no telemetry check, no telemetry e2e exclusion +├── aidd_docs/product/{cost-report,metrics}-contract.md ❌ +├── aidd_docs/runs/ ❌ +├── aidd_docs/tasks/2026_09/{the-upward-link,flow-and-versions,install-surfaces-agree, fewer-verbs,one-place-per-nature,telemetry-at-scale} ✏️ status: superseded +├── cli/aidd_docs/memory/telemetry.md, cli/.claude/skills/telemetry/ ❌ +└── memory, README, docs/*.md, cli/README.md, cli/ARCHITECTURE.md ✏️ no claim about the previous telemetry +``` + +## User Journey + +```mermaid +flowchart TD + A[Contributor commits on the repo] --> B[No trailer job runs, commit succeeds] + C[User runs the SDLC or spec or plan skill] --> D[No backlog-link.json written, no step-end echo] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Check out the branch with hooks installed => lefthook ready: 5: system + section Happy path + Run the repository scripts suite => green with no telemetry test left: 5: cli + Run pre-commit on a commit => every job green, no trailer job: 5: cli + section Edge case - dangling reference + A doc or skill still names a deleted file => markdown links and comments-name-files-that-exist fail: 1: cli +``` + +## Tasks to do + +### `1)` Empty the plugin and unhook the other skills + +> No plugin emits or writes anything for the previous telemetry. + +1. Delete the aidd-telemetry hooks and skills; leave `plugin.json` and a README saying the plugin is being rebuilt. +2. Remove the backlog-link step, reference, template and test rows from `aidd-pm:04-spec` and `aidd-dev:01-plan`, and the paragraph from `01-sdlc/references/01-frame.md`. +3. Remove every `aidd:step-end` echo and the SDLC's "Say when this orchestration is over" section. + +### `2)` Scripts, hooks, CI + +> The repository's own gates no longer mention the previous telemetry. + +1. Delete the telemetry script tests, their fixtures and the two telemetry scripts. +2. Remove the `prepare-commit-msg` trailer job from `lefthook.yml`, and the prompts-doc job if telemetry was its only input. +3. Remove the journal replay, `telemetry check` and telemetry e2e exclusion from the workflows. + +### `3)` Docs and task history + +> Docs describe what exists; past work stays readable as history. + +1. Delete the two product contracts, `aidd_docs/runs/`, the CLI telemetry memory and skill. +2. Rewrite each memory, README and docs line that claims the previous telemetry (list in the explore notes) to say nothing or point at phase 9's docs. +3. Mark the pending telemetry task folders `status: superseded`; leave done folders untouched. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `grep -rn "backlog-link\|aidd:step-end" plugins` finds nothing | +| 2 | `node --test 'scripts/__tests__/**/*.test.js'` and `lefthook run pre-commit` are green | +| 2 | A commit in a clone that has a stale `aidd-session-trailer.sh` delegate, and one that has none, both succeed | +| 3 | markdown-links, referenced-paths, comments-name-files-that-exist and the CATALOG/README count checks are green | +| 3 | kanban shows no pending telemetry task from before this plan | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-3.md new file mode 100644 index 000000000..df6b85aa5 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-3.md @@ -0,0 +1,93 @@ +--- +status: pending +--- + + + +# Instruction: Count, Claude transcripts to usage records + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/contexts/telemetry/ +│ ├── domain/usage-record.ts ✅ the common record (context/usage-record-contract.md) +│ ├── domain/formats/claude-transcript-usage.ts ✅ one transcript line to candidate records, pure +│ ├── domain/usage-fold.ts ✅ global fold: one record per key, deterministic winner +│ ├── domain/ports/transcript-source.ts ✅ list files, read bytes from an offset +│ └── infrastructure/claude-transcript-source-adapter.ts ✅ recursive walk of /projects +└── tests/ + ├── fixtures/claude-usage/ ✅ ported from context/prototype/fixtures, synthetic ids only + ├── contexts/telemetry/domain/*.unit.test.ts ✅ one test per rule, each with its mutation noted + └── contexts/telemetry/infrastructure/claude-transcript-source-adapter.integration.test.ts ✅ +``` + +## User Journey + +```mermaid +flowchart TD + A[Transcripts: main, resumed copy, subagent, set-aside] --> B[Per-line candidates] + B --> C[Global fold by key] + C --> D[One usage record per billed call, advisor calls apart] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Copy the claude-usage fixtures to a temp config dir => transcripts in place: 5: system + section Happy path + Read and fold all transcripts => exactly the expected records, field for field: 5: system + Read the same files again, in reverse order => identical records: 5: system + section Edge case - streaming and resumed copies + Same key in three lines with growing totals => fold => the largest total wins: 1: system + section Edge case - advisor iterations + A message with advisor iterations => fold => one main record plus one advisor record per iteration: 1: system + section Edge case - cache writes across iterations + Later iteration writes 1h cache => fold => cache_write and cache_write_1h are iteration sums: 1: system + section Edge case - unknown shape + A usage line missing a counter => read => record field unknown and the shape reported, never zero: 1: system + section Edge case - unterminated last line + File ends mid-line => read => the partial line is not consumed: 1: system +``` + +## Tasks to do + +### `1)` The record and the per-line rules + +> A pure function turns one transcript line into zero or more candidate records. + +1. Define `UsageRecord` exactly as `context/usage-record-contract.md`, `tool: "claude-code"`; unknown is `null`. +2. Key `message.id:requestId`, else `message.id:sessionId:timestamp`; drop ``; `agent` is `subagent` when `isSidechain` or `agentId`. +3. `cache_write` and `cache_write_1h` summed over `iterations[type=message]`, top-level when there are none; `cache_write_1h` unknown when any split is missing. +4. Each `iterations[type=advisor_message]` is its own record, key `#advisor`, its own model. +5. A recognisable usage line with an unexpected shape yields an "unrecognised" outcome counted and surfaced, never a guessed record. + +### `2)` The global fold + +> Order-independent: one winner per key. + +1. Winner is the largest total; tie, the earliest `at`; tie, the lowest `session_id`. +2. Fold across every file, not per file. + +### `3)` The transcript source + +> Every transcript under the projects root, read from a byte offset. + +1. Walk `/projects` recursively, including `subagents/` and `*.jsonl.superseded-*` set-asides. +2. Read from a given byte offset; never consume an unterminated last line; report file size and identity (device+inode or equivalent) so a shrunk or replaced file is re-read whole. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Each rule has a unit test that fails when that rule alone is mutated (iteration sum, advisor key, synthetic filter, sidechain agent, requestless key) | +| 2 | Reading files in any order gives the same records; mutating the tie-break or folding per file turns a named test red | +| 3 | Subagent and set-aside files are found; a file appended mid-line yields its complete lines only; a truncated file is re-read whole | +| all | No fixture holds a real session id, prompt, path or name | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-4.md new file mode 100644 index 000000000..63ab78c37 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-4.md @@ -0,0 +1,102 @@ +--- +status: pending +--- + + + +# Instruction: Store, the ledger, ingest and consent + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/ +│ ├── cli.ts ✏️ register the telemetry group +│ ├── presentation/commands/telemetry.ts ✅ group + `ingest` (hidden from the skill surface, used by the hook) +│ ├── runtime/wiring/telemetry.ts ✅ +│ ├── runtime/git/git-adapter.ts ✏️ rev-parse roots, origin url, root commit, config get/set/unset, branch reflog creation +│ └── contexts/telemetry/ +│ ├── domain/repository-identity.ts ✅ hash(host/owner/repo) from origin, else root commit +│ ├── domain/telemetry-consent.ts ✅ .aidd/config.json telemetry.enabled + AIDD_TELEMETRY=0 +│ ├── domain/ports/{usage-ledger,repository-locator,binding-snapshot-store}.ts ✅ +│ ├── application/ingest-usage-use-case.ts ✅ +│ └── infrastructure/{usage-ledger-adapter,repository-locator-adapter,binding-snapshot-store-adapter}.ts ✅ +└── tests/… ✅ unit, integration, e2e (sandboxed HOME) +``` + +## User Journey + +```mermaid +flowchart TD + A[aidd telemetry ingest, or any telemetry command] --> B[Lock the ledger] + B --> C[Read new bytes of every transcript] + C --> D[Resolve each line's cwd to a repository] + D --> E{Repository opted in?} + E -- no --> F[Not stored] + E -- yes --> G[Upsert by key, fold, snapshot branch bindings, unlock] + D -- cwd gone, never seen alive --> H[Not stored, counted in coverage] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Sandbox HOME with fixture transcripts and two git repos, one opted in => ready: 5: system + section Happy path + Run aidd telemetry ingest twice => the ledger holds each key once, second run adds nothing: 5: cli + section Edge case - concurrent ingests + Two ingests start together => both finish => no duplicate, no torn line: 1: cli + section Edge case - stale lock + A lock left by a dead pid => ingest => lock cleared and ingest completes: 1: cli + section Edge case - deleted working dir + cwd seen alive at an earlier ingest, now deleted => ingest => resolved from the persisted resolution, stored: 1: cli + cwd never seen alive => ingest => not stored, counted as unresolved in coverage: 1: cli + section Edge case - refusal + AIDD_TELEMETRY=0, or the project not opted in => ingest => nothing stored for it: 1: cli + section Teardown + Remove the sandbox => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Repository resolution + +> Each line is tied to a repository at ingest, from its own cwd. + +1. realpath + case-fold the cwd, `git rev-parse --show-toplevel --git-common-dir`, cached per cwd. +2. Repository id: hash of `host/owner/repo` parsed from `origin` (https and ssh, credentials stripped), else the root commit SHA; the raw url is never stored. +3. Every resolution `cwd -> {repository id, root, consent}` is persisted in `/ledger/roots.json` the first time the cwd is seen alive, so lines from a worktree deleted later still resolve. A cwd never seen alive is not stored (its consent is unprovable) and is counted in coverage. Outside any repo: not stored, counted as `outside-repo` in coverage. +4. Snapshot every opted-in repository's branch bindings (git config keys plus the branch creation time read from its reflog at that moment) into `/bindings/branches.jsonl`, append-only, on each ingest and each declaration. Attribution reads only snapshots, so a deleted or renamed branch keeps its past attribution. + +### `2)` The ledger + +> Append-only, idempotent, safe under concurrency. + +1. `/ledger/.jsonl` by record `at`; offsets in `/ledger/offsets.json`. +2. Upsert by `(tool, key)` with the phase 3 fold; a newline guard before each append. +3. Lock file with pid and creation time; a lock whose pid is gone or older than its timeout is cleared. +4. Directory 0700 and files 0600 on POSIX, icacls on Windows, as `auth-storage` does. + +### `3)` Consent and ingest + +> Only opted-in projects are read into the ledger. + +1. Consent per repository root: `.aidd/config.json` `telemetry.enabled === true` **and** `telemetry.version === 2`. The previous version's bare `enabled: true` is not consent. `AIDD_TELEMETRY=0` refuses everything. Consent is checked when the cwd is first resolved. +2. `ingest` is the one use case; every later telemetry command calls it first. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | https, ssh and credential-bearing remotes of one repo give one id; a repo without origin gets its root commit; a linked worktree resolves to its main repository's id | +| 2 | Re-ingesting the same transcripts never changes the ledger; two concurrent ingests leave each key once | +| 2 | A transcript rewritten shorter is re-read whole and the fold keeps the best record | +| 3 | A project without consent, and any project under `AIDD_TELEMETRY=0`, stores nothing | +| 1 | A branch deleted after its declaration still attributes its past records, read from the snapshot | +| all | Each rule (key fold, lock clearing, consent refusal, snapshot) has a named mutation that turns its test red | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-5.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-5.md new file mode 100644 index 000000000..2545240e6 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-5.md @@ -0,0 +1,89 @@ +--- +status: pending +--- + + + +# Instruction: Declare, the task command and its stores + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/src/ +├── presentation/commands/telemetry.ts ✏️ `task [--ticket ] | --none`, `task` alone shows the current binding +└── contexts/telemetry/ + ├── domain/task-declaration.ts ✅ name, ticket, none, declared_at, by (command | hook-intercept) + ├── domain/ports/{branch-binding-store,session-binding-store}.ts ✅ + ├── application/declare-task-use-case.ts ✅ + └── infrastructure/ + ├── branch-binding-store-adapter.ts ✅ git config branch..aiddTask / aiddTicket / aiddDeclaredAt + └── session-binding-store-adapter.ts ✅ /bindings/sessions.jsonl, append-only +scripts/__tests__/fixtures/telemetry-bindings/ ✅ the shared format fixture (read by CLI and hook tests) +``` + +## User Journey + +```mermaid +flowchart TD + A[Person runs aidd telemetry task checkout-fix --ticket PROJ-12] --> B{Inside a Claude session?} + B -- yes --> C[Bind that session from now] + B -- no --> D[No session to bind] + C --> E{On a working branch?} + D --> E + E -- yes, not yet bound --> F[Bind the branch] + E -- default branch or detached --> G[Branch untouched] + F --> H[Print what was bound] + G --> H +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Sandbox repo on feat/x, CLAUDE_CODE_SESSION_ID set => ready: 5: system + section Happy path + aidd telemetry task checkout-fix --ticket PROJ-12 => session and branch bound, both printed: 5: cli + aidd telemetry task => shows the binding and where it comes from: 5: cli + section Edge case - none + aidd telemetry task --none => declared none, never asked again on this branch: 1: cli + section Edge case - redeclare + A second declaration in the same session => applies from its own time, the first keeps the earlier work: 1: cli + section Edge case - default branch + On the default branch => only the session is bound, git config untouched: 1: cli + section Edge case - outside a session + No session id in env => branch bound only: 1: cli +``` + +## Tasks to do + +### `1)` The declaration and its two stores + +> One declaration, stored where its scope lives. + +1. Ticket is free text, trimmed, never parsed or validated against a forge. +2. Branch store: `git config --local branch..aiddTask|aiddTicket|aiddDeclaredAt`, then an immediate snapshot (phase 4, task 1.4) with the branch creation time read from the reflog now, before it can expire. +3. Session store: one JSON line per declaration `{session_id, task, ticket, none, declared_at, by}`; the latest per session wins from its own time. +4. Default branch (`origin/HEAD`, else `main`/`master` when no remote head) and detached HEAD never get a branch binding. + +### `2)` The shared format fixture + +> One file both languages test against. + +1. Fixture holds sample `sessions.jsonl`, `carries.jsonl` (phase 6) and the git config keys, with the expected "is this session bound, to what" answers, plus cases for every rule the hook duplicates: telemetry-dir resolution, default-branch detection, consent, and the Claude-only guard. +2. A CLI unit test reads it; phase 6's hook test reads the same file. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | After a declaration on `feat/x`, `git config branch.feat/x.aiddTask` returns the task; renaming the branch keeps it | +| 1 | On the default branch or detached HEAD, git config is unchanged | +| 1 | Two declarations in one session are both kept, each with its own time | +| 2 | Changing a field name in the CLI writer without updating the fixture turns the fixture test red | +| all | Default-branch exclusion, latest-wins per session, and the none declaration each have a named mutation that turns their test red | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-6.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-6.md new file mode 100644 index 000000000..59242ab16 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-6.md @@ -0,0 +1,126 @@ +--- +status: pending +--- + + + +# Instruction: Ask, the plugin hooks + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +plugins/aidd-telemetry/hooks/ +├── hooks.json ✅ SessionStart (sync: session fact, clear carry), SessionStart (async: ingest), UserPromptSubmit +├── session-start.cjs ✅ records {pid, session, source}; on clear, carries the predecessor's session binding +├── catch-up.cjs ✅ async: runs `aidd telemetry ingest` if aidd is on PATH, prints nothing +├── prompt-gate.cjs ✅ intercept a typed declaration, else ask when the rule says so +└── lib/ + ├── claude-only.cjs ✅ payload session_id === CLAUDE_CODE_SESSION_ID, Claude-shaped transcript path + ├── presence.cjs ✅ ATTENDED=1 and ENTRYPOINT not sdk*, else absent + ├── binding.cjs ✅ reads git config + sessions.jsonl + carries.jsonl, answers "bound to what" + └── telemetry-dir.cjs ✅ AIDD_TELEMETRY_DIR, else AIDD_USER_CONFIG_DIR/telemetry, XDG, ~/.config/aidd/telemetry +scripts/__tests__/aidd-telemetry-hooks.test.js ✅ spawns each hook with payloads and a sandbox env +``` + +## User Journey + +```mermaid +flowchart TD + P[Person submits a prompt] --> Q{Claude, opted in, person present?} + Q -- no --> R[Pass, silent] + Q -- yes --> S{Prompt is aidd telemetry task ...?} + S -- yes --> T[Run the CLI declaration, block with the result, original prompt suppressed] + S -- no --> U{Working branch and nothing bound?} + U -- no --> R + U -- yes --> V[Block: declare a task, two ways shown] + C[/clear] --> W[Session fact: same pid, new session] + W --> X[Copy predecessor's session binding as carried, announce it] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Temp repo on feat/x opted in, sandbox telemetry dir, Claude-shaped payloads => ready: 5: system + section Happy path + Prompt on unbound feat/x with ATTENDED=1 ENTRYPOINT=cli => blocked, reason names the command: 5: system + Prompt "aidd telemetry task fix-cart --ticket PROJ-1" => CLI called, blocked with confirmation, original prompt suppressed: 5: system + Next prompt => passes: 5: system + section Edge case - unattended + ATTENDED=0 or ENTRYPOINT=sdk-cli or variable absent => passes: 1: system + section Edge case - not Claude + Codex-shaped payload, or session_id differing from CLAUDE_CODE_SESSION_ID => passes, writes nothing: 1: system + section Edge case - default branch and detached + On main or detached HEAD => passes: 1: system + section Edge case - clear + SessionStart clear with the same CLAUDE_PID => carry written, systemMessage names the task: 1: system + section Edge case - opted out + Project not opted in, or AIDD_TELEMETRY=0 => every hook passes and writes nothing: 1: system + section Edge case - aidd missing + Declaration typed with no aidd on PATH => blocked with a message saying aidd is needed: 1: system +``` + +## Tasks to do + +### `1)` Guards shared by every hook + +> Inert outside Claude, outside opted-in projects, and when presence is unknown. + +1. Order for latency: env checks first (Claude-only, presence), then one file read (consent), then git. A person with the plugin but no consent pays no git call. +2. Claude-only: payload `session_id` equals `CLAUDE_CODE_SESSION_ID` and the transcript path is under a Claude `projects/` directory (separators normalised). +3. Consent read from `.aidd/config.json` at the repo root, `AIDD_TELEMETRY=0` refuses; the same rule as the CLI, pinned by a shared fixture case. +4. Every hook exits 0 on any internal error, except the deliberate block; a crash never blocks a prompt. + +### `2)` The prompt gate + +> Ask once per working branch, answer without the model. + +1. A prompt matching `^\s*!?\s*aidd telemetry task\b` is tokenized by a strict parser (no shell syntax accepted), and the CLI is spawned with an argv array and no shell, `aidd.cmd` resolved explicitly on Windows; it then blocks with the CLI's output and `suppressOriginalPrompt: true`. +2. Otherwise block only when: Claude, opted in, present, working branch, neither the session (declared or carried) nor the branch is bound, and the answer path works: `aidd` resolves and `aidd telemetry task --help` succeeds (checked only on this rare path). Otherwise pass silently: a block nobody can answer is never raised. +3. The reason names both answers: `! aidd telemetry task [--ticket ]` in a terminal, the same text as a prompt anywhere. + +### `3)` Session facts and the clear carry + +> `/clear` keeps the task, visibly. + +1. On every SessionStart, append `{pid, session_id, source, at}` to the pid's history, pid from `CLAUDE_PID` (undocumented: pinned by a test, falling back to the parent pid as measured, and to no carry when neither is available). +2. On `source: clear`, take the pid's latest session before this one; if it is bound (declared or carried), append `{session_id, from, at}` to `carries.jsonl` and print a `systemMessage` naming the task and how to change it. +3. `resume` uses the resumed session's own binding; `fork` carries from the session it forked from when the history shows it (same work), probed once on `/branch`; `startup` and `compact` carry nothing. + +### `4)` Async catch-up + +> Ingest at session start, never visible, never blocking. + +1. A separate `async: true` SessionStart entry runs `aidd telemetry ingest` when `aidd` resolves on PATH; prints nothing to stdout. +2. Assert that `async: true` survives both Claude install routes (marketplace copy and the flat settings merge). + +### `5)` Presence on non-terminal surfaces + +> Know, not assume, what the IDE and desktop app expose. + +1. Probe once, with the maintainer, in the VS Code extension and the desktop app: `CLAUDE_CODE_SESSION_ATTENDED`, `CLAUDE_CODE_ENTRYPOINT`, and whether a block reason displays. Record the values in `context/target.md`. +2. Extend the presence rule only with values observed there; until observed, those surfaces never block. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | A Codex-shaped payload and a mismatched session id both pass and write no file | +| 1 | A hook given malformed JSON exits 0 and passes the prompt | +| 2 | An unbound working branch with a person present blocks; the same with ATTENDED=0 passes | +| 2 | A typed declaration reaches no model: the hook blocks and the CLI has stored it | +| 3 | After a clear on a bound session, the new session reads as bound to the same task, marked carried | +| 3 | The hook reads the phase 5 shared fixture and returns the same answers as the CLI test | +| 4 | The catch-up writes nothing to stdout and exits 0 when aidd is absent | +| 4 | A flat-merged `.claude/settings.json` keeps `async: true` on the catch-up entry | +| 2 | With `aidd` absent or lacking `telemetry task`, an unbound branch is never blocked | +| 2 | A typed declaration containing `;`, `&&`, a pipe or backticks runs no second command | +| 5 | Observed IDE and desktop values are recorded, with a test per observed value | +| all | Claude-only guard, presence rule, answer-path check and clear carry each have a named mutation that turns their test red | +| all | The suite passes on the Windows CI job | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-7.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-7.md new file mode 100644 index 000000000..955afd3c8 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-7.md @@ -0,0 +1,92 @@ +--- +status: pending +--- + + + +# Instruction: Attribute and report + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/src/ +├── presentation/commands/telemetry.ts ✏️ `report [--from --to | --days] [--axis ] [--json]` +├── presentation/display/telemetry-report-display.ts ✅ text rendering only +└── contexts/telemetry/ + ├── domain/attribution.ts ✅ pure: record + bindings + carries + branch facts -> task/ticket or unattributed(reason) + ├── domain/usage-report.ts ✅ totals and axes, four counters apart + ├── domain/person.ts ✅ opted-in person id or none + ├── domain/ports/person-identity.ts ✅ + ├── application/report-usage-use-case.ts ✅ ingest, then attribute and aggregate + └── infrastructure/person-identity-adapter.ts ✅ userConfigDir()/telemetry/identity.json +``` + +## User Journey + +```mermaid +flowchart TD + A[aidd telemetry report --days 7] --> B[Ingest] + B --> C[Attribute every record] + C --> D[Aggregate by axis] + D --> E[Totals, one row per value, unattributed rows with reasons, coverage line] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Ledger fixture spanning two days, two models, two branches, one carry, one redeclaration => ready: 5: system + section Happy path + report --axis task => one row per task, unattributed rows by reason, sum equals total: 5: cli + report on every axis => each sums to the same total, four counters apart: 5: cli + section Edge case - retroactive branch + Records on feat/x before it is declared => report => unattributed no-binding; declare; report => on the task: 1: cli + section Edge case - carried then corrected + A carried session later declared => report => the whole session on the declared task: 1: cli + section Edge case - mid-session redeclaration + Second declaration at t => report => before t on the first task, after t on the second: 1: cli + section Edge case - unknown counters + A record with an unknown counter => report => that counter shown unknown for the row, never summed as zero: 1: cli +``` + +## Tasks to do + +### `1)` Attribution + +> One pure function, the command the only source. + +1. Order: session declaration effective at the record's time; else the session's carry (whole session until its first declaration); else the branch binding read from the snapshots, when the record's `git_branch` and repository match and its time is after the branch creation time captured in the snapshot; else unattributed. +2. A first declaration in a carried session applies to the whole session; later ones apply from their own time. +3. Unattributed reasons: `outside-repo`, `root-unresolved`, `no-binding`, `declared-none`. +4. Subagent and advisor records inherit their parent session's attribution through `session_id`. + +### `2)` The report + +> Every axis reconciles to one total. + +1. Axes: `total`, `person`, `session`, `model`, `day` (UTC), `repository`, `task`, `ticket`. +2. Four counters per row, and a row total; an unknown counter is shown as unknown for its row and excluded from no total silently: the report states how many records had an unknown. +3. A coverage line: files read, records, unrecognised shapes, and the oldest transcript date still on disk. +4. `--json` emits a versioned envelope documented in phase 9. + +### `3)` Person + +> Opt-in, fresh, local. + +1. `aidd telemetry identity ` writes `{person_id}` under the telemetry dir; `--off` removes it; absent means no person axis value. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Each attribution rule has a unit test and a named mutation that turns it red | +| 1 | Declaring a branch after its work moves that work from `no-binding` to the task on the next report | +| 2 | For every axis, the rows sum to the total on the fixture and on a property-based generated ledger | +| 2 | The JSON envelope carries its version, and the text never shows a path, cwd or branch name it does not need | +| 3 | Without an identity file, the person axis shows one "not set" row; with one, the id | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-8.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-8.md new file mode 100644 index 000000000..a18a67104 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-8.md @@ -0,0 +1,85 @@ +--- +status: pending +--- + + + +# Instruction: Opt in, opt out, forget, and what V1 left behind + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/src/ +├── presentation/commands/telemetry.ts ✏️ `on [--yes]`, `off`, `forget [--yes]` +└── contexts/telemetry/ + ├── domain/legacy-leftovers.ts ✅ what V1 wrote, by location + ├── application/{telemetry-on,telemetry-off,forget-telemetry}-use-case.ts ✅ + └── infrastructure/legacy-leftovers-adapter.ts ✅ finds and removes them +``` + +## User Journey + +```mermaid +flowchart TD + A[aidd telemetry on] --> B[Write telemetry.enabled] + B --> C[Remove V1 leftovers in this repo] + C --> D[Report Claude transcript retention and the one-line fix] + E[aidd telemetry forget] --> F[Show what would go] + F -- --yes --> G[Remove ledger, bindings, identity, V1 sink and identity] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Sandbox repo carrying V1 leftovers (trailer line + delegate, aidd_docs/runs, ignore entry) and a V1 sink + identity => ready: 5: system + section Happy path + aidd telemetry on --yes => enabled, trailer line and delegate gone together, runs dir and ignore entry gone: 5: cli + git commit => succeeds, no AIDD-Session-Id trailer: 5: cli + aidd telemetry forget --yes => ledger, bindings, identity, V1 day files and V1 identity removed: 5: cli + section Edge case - foreign hook content + prepare-commit-msg has other lines => on => only the trailer line removed, the rest intact: 1: cli + section Edge case - hook managers + lefthook or husky manages the hook => on => the delegate is kept if a manager line still calls it, and the manual step is printed: 1: cli + section Edge case - forget preview + forget without --yes => lists what would go, removes nothing: 1: cli + section Edge case - retention + Claude cleanupPeriodDays unset or below 3650 => on => says so and prints the setting to add, writes nothing in the Claude profile: 1: cli + section Teardown + Remove the sandbox => baseline restored: 5: system +``` + +## Tasks to do + +### `1)` Opt in and V1 leftovers + +> Enabling leaves the repository as if V1 had never run. + +1. `on` writes `.aidd/config.json` `telemetry: {enabled: true, version: 2}`, keeping other keys; `--yes` skips the confirmation. A repository carrying only the previous version's `enabled: true` stays off until `on` runs. +2. Remove the `prepare-commit-msg` line calling `aidd-session-trailer.sh` and the delegate script in the same step; never remove the script while a line still calls it. +3. Remove `aidd_docs/runs/` (V1 journal, git-ignored) and its `.gitignore` entry. +4. Read Claude's effective `cleanupPeriodDays` (user and project settings, read only) and print the fix when it is below 3650. + +### `2)` Opt out and forget + +> Off stops reading; forget erases. + +1. `off` sets `telemetry.enabled: false`, keeping `version`; nothing else changes. +2. `forget` previews by default; `--yes` removes the ledger, bindings, carries, identity, V1 day files at the telemetry dir root and V1's `identity.json`, and the git config `aiddTask|aiddTicket|aiddDeclaredAt` keys of every still-existing repository the binding snapshots name. +3. The previous version's locations come from phase 1's notes: sink dir under `AIDD_TELEMETRY_DIR`, `AIDD_USER_CONFIG_DIR/telemetry`, `%APPDATA%\aidd\telemetry` on Windows, `~/.config/aidd/telemetry`; identity at `%APPDATA%\aidd\identity.json` or `~/.config/aidd/identity.json`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | After `on` in a V1 repo, a commit succeeds and carries no AIDD-Session-Id trailer | +| 1 | A prepare-commit-msg with other content keeps that content byte for byte | +| 1 | Nothing under the real or sandboxed Claude profile is written | +| 2 | `forget` without `--yes` changes nothing on disk; with it, nothing of either version remains in the telemetry dir | +| all | Trailer line-and-script pairing, foreign-content preservation and forget's preview each have a named mutation that turns their test red | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-9.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-9.md new file mode 100644 index 000000000..d23490906 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/phase-9.md @@ -0,0 +1,85 @@ +--- +status: pending +--- + + + +# Instruction: Skills, docs, and the end-to-end proof + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +├── plugins/aidd-telemetry/ +│ ├── skills/00-init/ ✅ opt in, identity, forget, through the CLI +│ ├── skills/01-usage/ ✅ answer "how many tokens" questions with `aidd telemetry report` +│ ├── README.md ✏️ what it measures, what it asks and when, what stays local +│ └── CATALOG.md ✏️ regenerated +├── aidd_docs/product/usage-contract.md ✅ the usage record, the report envelope, the binding stores +├── cli/aidd_docs/memory/telemetry.md ✅ how the context is built +├── memory, README, docs/*.md ✏️ the new telemetry, one line each where relevant +└── cli/tests/e2e/telemetry-journey.e2e.test.ts ✅ the full journey on the built binary +``` + +## User Journey + +```mermaid +flowchart TD + A[Opt in] --> B[Work: asked once on a working branch] + B --> C[/clear, keep working, carried/] + C --> D[Headless run on the same branch] + D --> E[Report: per task, ticket, model, day, session, person, repository] + E --> F[Every axis equals the total] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + Built binary, sandbox HOME and CLAUDE_CONFIG_DIR, a repo on feat/x, synthetic transcripts for three sessions => ready: 5: cli + section Happy path + on, hook block, typed declaration, clear carry, headless session, report --json => each axis sums to the total, tokens land on the declared task and ticket: 5: cli + section Edge case - real history oracle + Ingest a copy of local history in a sandbox => daily totals equal an independent count of the same transcripts, the reference's cache-write under-count explained: 1: cli + section Teardown + Delete the sandbox => no file outside it touched: 5: system +``` + +## Tasks to do + +### `1)` Skills + +> Two skills, both thin over the CLI, saying loudly when it is absent. + +1. `00-init`: opt in after consent, set or remove the identity, forget with a preview. +2. `01-usage`: map a question to `aidd telemetry report` flags and read the JSON; never compute a number itself. + +### `2)` Docs + +> One contract, written once. + +1. `aidd_docs/product/usage-contract.md`: the record, the envelope version, the stores and their owners, the ask rule, the carry rule. +2. Plugin README, CLI memory and repo memory lines. +3. Set the bundle budget once, to the measured build plus about 2 %, with its registry line. + +### `3)` Proof + +> The whole journey on the built binary, and one comparison on real data. + +1. One e2e journey covering the spec's done-when list, no AI tool binary on PATH. +2. A manual, documented oracle run on a copied history in a sandbox, comparing daily totals with an independent count; its output kept in this folder, with no personal data. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Each skill's actions name only commands that exist in the CLI help golden | +| 2 | Every doc link resolves; no doc mentions the previous telemetry's journal, envelope or commands | +| 3 | The e2e journey passes on Linux, macOS and Windows CI | +| 3 | The oracle note shows equal totals for every compared day, or each difference explained to the token | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/plan.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/plan.md new file mode 100644 index 000000000..7fd045cad --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/plan.md @@ -0,0 +1,55 @@ +--- +objective: "Every billed Claude Code call is counted once, stored locally, and broken down by person, session, model, period, repository, task and ticket, the task coming only from a declaration, with the previous telemetry gone." +status: pending +--- + + + +# Plan: Deterministic token measurement, release 1 + +## Overview + +| Field | Value | +| ---------- | --------------------------------------------------------- | +| **Goal** | Replace the previous telemetry with a deterministic, declaration-attributed token count for Claude Code | +| **Source** | [`spec.md`](./spec.md), with [`context/target.md`](./context/target.md) and [`context/usage-record-contract.md`](./context/usage-record-contract.md) | + +## Phases + +| # | Phase | File | +| --- | -------------------------------------------------- | ---------------------------- | +| 1 | Remove the previous telemetry from the CLI | [`phase-1.md`](./phase-1.md) | +| 2 | Remove it from the plugins, scripts, CI and docs | [`phase-2.md`](./phase-2.md) | +| 3 | Count: Claude transcripts to usage records | [`phase-3.md`](./phase-3.md) | +| 4 | Store: the ledger, ingest, consent | [`phase-4.md`](./phase-4.md) | +| 5 | Declare: the task command and its stores | [`phase-5.md`](./phase-5.md) | +| 6 | Ask: the plugin hooks | [`phase-6.md`](./phase-6.md) | +| 7 | Attribute and report | [`phase-7.md`](./phase-7.md) | +| 8 | Opt in, opt out, forget, and what V1 left behind | [`phase-8.md`](./phase-8.md) | +| 9 | Skills, docs, and the end-to-end proof | [`phase-9.md`](./phase-9.md) | + +Phases 1 and 2 land as their own commits but never ship alone: the branch merges only once +phase 9 is green, so `next` never carries a CLI without measurement. + +## Resources + +| Source | Verified | +| ------ | -------- | +| https://code.claude.com/docs/en/hooks | `UserPromptSubmit` blocks before the model; a blocked prompt's message ends with `Original prompt:` and is written to the transcript unless `hookSpecificOutput.suppressOriginalPrompt` is true; it also fires on turns Claude starts on its own, with no field telling them apart; `async: true` cannot block and its `systemMessage`/`additionalContext` go to the model on the next turn; `-p` kills a running async hook at teardown; `SessionStart.source` is `startup \| resume \| clear \| compact \| fork` | +| https://code.claude.com/docs/en/env-vars | `CLAUDE_CODE_SESSION_ID` is set in hooks and Bash tool subprocesses, matches the hook's `session_id`, and is updated on `/clear`. `CLAUDE_CODE_ENTRYPOINT` and `CLAUDE_CODE_SESSION_ATTENDED` are not documented | +| https://learn.chatgpt.com/docs/hooks | Codex `UserPromptSubmit` can block (`decision: block` or exit 2): Codex delivers the same event name, so the hook must refuse non-Claude hosts itself | +| Sandbox probes, Claude Code 2.1.295 ([`context/target.md`](./context/target.md)) | `ATTENDED=1`/`ENTRYPOINT=cli` interactive, `ATTENDED=0`/`sdk-cli` under `-p`; `CLAUDE_CODE_CHILD_SESSION=1` in both; `!` commands see the session id and fire no `UserPromptSubmit`; `/clear` changes the session id, keeps `CLAUDE_PID`, carries no previous id; a hook `systemMessage` shows as `SessionStart:clear says: …`; a blocked `-p` exits 0; a commit hook line naming a missing script fails the commit | + +## Decisions + +| Decision | Why | +| -------- | --- | +| The CLI owns reading, storing, attributing and reporting; the plugin hooks only ask, intercept the answer, and record session facts | One implementation per concern. The hook runs on every prompt, so it stays plain node with no `aidd` spawn on the common path (61 ms against 241 ms measured for V1) | +| The declaration command is `aidd telemetry task [--ticket ] \| --none` | The task exists only for measurement; it lives under the measurement command group, not as a new top-level verb | +| A branch declaration is stored in the repository's own git config (`branch..aiddTask`, `aiddTicket`, `aiddDeclaredAt`); session declarations and `/clear` carries are stored in the person's telemetry directory | git config follows a branch rename and dies with the branch, and it needs no file in the work tree; a session is per person and machine | +| The hook acts only when the payload's `session_id` equals `CLAUDE_CODE_SESSION_ID` and the payload is Claude-shaped | Codex and Copilot deliver `UserPromptSubmit` under the same name, and a Codex nested in Claude inherits Claude's variable: equality makes the hook inert elsewhere by construction | +| Block only when `CLAUDE_CODE_SESSION_ATTENDED=1` and `CLAUDE_CODE_ENTRYPOINT` does not start with `sdk`; anything else never blocks | Both variables are undocumented; requiring both to agree, and failing open, means a change in Claude can only stop the asking, never block work wrongly | +| The format the CLI writes and the hook reads (bindings, carries) is pinned by one shared fixture under `scripts/__tests__/fixtures/telemetry-bindings/`, read by a CLI test and a hook test | Two languages, one format: the fixture is the contract, so neither side can drift silently | +| Ledger under `/ledger/`, where the telemetry dir is `AIDD_TELEMETRY_DIR`, else `userConfigDir()/telemetry` | A subdirectory keeps it apart from V1's day files at the root, which `forget` removes; `userConfigDir()` honours `AIDD_USER_CONFIG_DIR`, which V1's identity did not | +| Consent is `telemetry.enabled: true` with `telemetry.version: 2`; the previous version's bare `enabled: true` is ignored | Nobody is asked or measured by the new version without having opted in to it; `on` also cleans what the previous version left | +| Skills in other plugins never call the telemetry command in release 1 | A plugin must not depend on the telemetry plugin; the hook's ask covers every case. A skill-side pre-emption is a follow-up | diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md index ea16cd875..71cbf9ae2 100644 --- a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md @@ -41,8 +41,8 @@ coming only from what a person declared, with the previous telemetry removed ent - Work with no declared task is its own row, with the reason it has none, and is attributed retroactively once its branch is declared. - Every breakdown of a period sums to the same total. -- A project or person that has not opted in is untouched: nothing read, nothing asked, nothing - blocked. Without the plugin, nothing changes at all. +- A project or person that has not opted in is untouched: nothing stored, nothing asked, + nothing blocked. Without the plugin, nothing changes at all. - Data stays on the machine: release 1 sends nothing anywhere. - The person identity is a fresh, separate opt-in, located with the rest of the person's aidd configuration; the previous identity file is not carried over. @@ -73,8 +73,8 @@ coming only from what a person declared, with the previous telemetry removed ent any one rule turns its own test red. - On real local history, the release's daily totals equal an independent reference count of the same transcripts, the known reference under-count of cache writes aside and explained. -- A person present on an unbound working branch, in the terminal or an IDE, cannot get a model - answer until they declare a task or declare none, and the declaration itself costs no model +- A person present on an unbound working branch, on every surface where presence is + detectable (the terminal at least), cannot get a model answer until they declare a task or declare none, and the declaration itself costs no model call; the default branch and a detached HEAD never ask. - A headless run on the same branch completes unblocked and its tokens land on the branch's task, or on the unattributed row until the branch is declared, then on the task. From f077c12ab1fb6b5a8403b375d12b12e850113fc6 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 9 Oct 2026 18:47:48 +0200 Subject: [PATCH 03/73] refactor(telemetry)!: remove the previous telemetry The previous measurement is replaced by a deterministic, declaration-attributed one in the following commits of this branch. This commit removes the old one so nothing half-describes the new one. cli: delete the telemetry context, its wiring, command, displays and tests, and everything that existed only to serve it: the measurement vocabulary, the per-tool telemetry declarations and transcript locations, the git adapter that installed the commit-trailer hook, the identity and sink resolvers, the FileWriter chmodExecutable method and the run-journal gitignore entry. The OpenCode hooks bridge no longer names the deleted plugin module in its generated comments; the framework build golden is re-baselined for that one line. Architecture guards, biome overrides, mutation scope, smoke steps and the help golden describe the CLI that remains. plugins: delete the aidd-telemetry hooks and skills (README reduced to a stub, manifest description updated). Remove the backlog-link.json step, reference and template from aidd-pm:04-spec and aidd-dev:01-plan, the backlog-link paragraph from the SDLC frame, and every aidd:step-end echo with the SDLC, async-dev and backlog "Say when this orchestration is over" sections. scripts and CI: delete the telemetry script tests, their fixtures, the two telemetry scripts, the generated prompts documentation and its lefthook job, the prepare-commit-msg trailer job, the journal replay, telemetry check and telemetry e2e exclusion in cli-ci.yml, and the identifier-join probe. Tests that used the telemetry plugin as a byte fixture read aidd-context instead. docs: delete the cost-report and metrics contracts and aidd_docs/runs/, rewrite every memory, README and docs line that claimed the previous telemetry, and mark the pending telemetry task folders of 2026_09 as superseded. The previous version's on-disk locations are recorded in aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md for the opt-in and forget phases. BREAKING CHANGE: the aidd telemetry commands, the aidd-telemetry hooks and skills, backlog-link.json and the AIDD-Session-Id commit trailer are removed; measurement returns, for Claude Code first, later on this branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .github/workflows/cli-ci.yml | 71 +- .github/workflows/validate.yml | 4 +- .gitignore | 13 +- README.md | 6 +- aidd_docs/memory/architecture.md | 2 +- aidd_docs/memory/cli.md | 3 +- aidd_docs/memory/codebase-map.md | 1 - aidd_docs/memory/coding-assertions.md | 2 +- aidd_docs/memory/deployment.md | 2 +- ...surement-may-reach-a-hosted-destination.md | 4 +- aidd_docs/memory/project-brief.md | 1 - aidd_docs/product/cost-report-contract.md | 694 --- aidd_docs/product/metrics-contract.md | 925 ---- aidd_docs/runs/.gitkeep | 0 aidd_docs/runs/README.md | 76 - .../2026_09/2026_09_01_fewer-verbs/frame.md | 4 + .../2026_09_01_flow-and-versions/phase-1.md | 2 +- .../2026_09_01_flow-and-versions/phase-2.md | 2 +- .../2026_09_01_flow-and-versions/plan.md | 2 +- .../2026_09_01_flow-and-versions/spec.md | 4 + .../2026_09_01_one-place-per-nature/frame.md | 4 + .../2026_09_01_the-upward-link/phase-1.md | 2 +- .../2026_09_01_the-upward-link/phase-2.md | 2 +- .../2026_09_01_the-upward-link/phase-3.md | 2 +- .../2026_09_01_the-upward-link/plan.md | 2 +- .../2026_09_01_the-upward-link/spec.md | 4 + .../2026_09_02_install-surfaces-agree/plan.md | 2 +- .../2026_09_02_install-surfaces-agree/spec.md | 2 +- .../measurements.md | 4 + .../context/previous-version-leftovers.md | 68 + .../rules/00-architecture/0-contexts.md | 1 - cli/.claude/skills/framework/SKILL.md | 4 +- cli/.claude/skills/telemetry/SKILL.md | 68 - cli/.claude/skills/test/SKILL.md | 2 +- cli/ARCHITECTURE.md | 4 +- cli/CLAUDE.md | 1 - cli/README.md | 24 +- cli/aidd_docs/memory/README.md | 1 - cli/aidd_docs/memory/architecture.md | 6 - cli/aidd_docs/memory/cli.md | 3 +- cli/aidd_docs/memory/codebase-map.md | 9 - cli/aidd_docs/memory/deployment.md | 2 +- cli/aidd_docs/memory/telemetry.md | 39 - cli/aidd_docs/memory/testing.md | 2 +- cli/aidd_docs/memory/vcs.md | 1 - cli/biome.json | 113 +- cli/mutation-scopes.json | 4 - cli/package.json | 1 - cli/scripts/smoke-tools.sh | 59 +- cli/src/cli.ts | 2 - .../framework/application/clean-use-case.ts | 2 +- .../application/gitignore-use-case.ts | 3 +- .../domain/manifest-gitignore-entries.ts | 4 +- .../user-manifest-repository-adapter.ts | 2 +- .../diagnose-telemetry-use-case.ts | 341 -- .../application/forget-telemetry-use-case.ts | 152 - .../application/person-identity-use-case.ts | 168 - .../application/read-local-cost-use-case.ts | 502 --- .../application/report-cost-use-case.ts | 447 -- .../application/telemetry-off-use-case.ts | 103 - .../application/telemetry-on-use-case.ts | 151 - .../telemetry/domain/cost-report-envelope.ts | 424 -- .../contexts/telemetry/domain/cost-report.ts | 760 ---- .../telemetry/domain/flow-attribution.ts | 74 - .../domain/formats/claude-code-transcript.ts | 300 -- .../telemetry/domain/formats/codex-rollout.ts | 204 - .../domain/formats/commit-session-trailer.ts | 110 - .../domain/formats/copilot-events.ts | 106 - .../domain/formats/opencode-export.ts | 120 - .../telemetry/domain/journal-intervals.ts | 112 - .../telemetry/domain/person-resolution.ts | 85 - .../domain/ports/hook-trust-reader.ts | 10 - .../telemetry/domain/ports/ignore-entries.ts | 9 - .../domain/ports/installed-plugins-reader.ts | 26 - .../domain/ports/person-identity-reader.ts | 23 - .../domain/ports/person-identity-store.ts | 44 - .../domain/ports/run-journal-reader.ts | 139 - .../domain/ports/session-cost-reader.ts | 45 - .../domain/ports/task-backlog-reader.ts | 15 - .../domain/ports/telemetry-evidence-reader.ts | 54 - .../telemetry/domain/ports/telemetry-sink.ts | 59 - .../telemetry/domain/ports/version-control.ts | 85 - .../telemetry/domain/report-period.ts | 61 - .../telemetry/domain/report/axes/day-rows.ts | 37 - .../telemetry/domain/report/axes/flow-rows.ts | 100 - .../domain/report/axes/person-rows.ts | 83 - .../domain/report/axes/record-stated-rows.ts | 162 - .../telemetry/domain/report/axes/step-rows.ts | 53 - .../telemetry/domain/report/axes/task-rows.ts | 255 -- .../telemetry/domain/report/axes/tool-rows.ts | 45 - .../domain/report/record-counters.ts | 21 - .../domain/report/record-reconciliation.ts | 169 - .../domain/report/report-selection.ts | 192 - .../telemetry/domain/report/row-ordering.ts | 42 - .../telemetry/domain/session-anchor.ts | 7 - .../telemetry/domain/session-project.ts | 58 - .../contexts/telemetry/domain/skill-name.ts | 17 - .../telemetry/domain/step-attribution.ts | 147 - .../telemetry/domain/task-attribution.ts | 95 - .../telemetry/domain/task-backlog-link.ts | 31 - .../telemetry/domain/task-identity.ts | 39 - .../telemetry/domain/telemetry-claim.ts | 484 --- .../domain/telemetry-export-leftover.ts | 36 - .../telemetry/domain/telemetry-removal.ts | 61 - .../telemetry/domain/telemetry-setup.ts | 202 - .../telemetry/domain/telemetry-sink-record.ts | 120 - .../domain/telemetry-sink-retention.ts | 21 - .../telemetry/domain/telemetry-switch.ts | 73 - .../copilot-cost-reader-adapter.ts | 25 - .../hook-trust-reader-adapter.ts | 50 - .../opencode-cost-reader-adapter.ts | 83 - .../infrastructure/person-identity-adapter.ts | 153 - .../run-journal-reader-adapter.ts | 298 -- .../infrastructure/task-backlog-adapter.ts | 55 - .../telemetry-evidence-adapter.ts | 275 -- .../infrastructure/telemetry-sink-adapter.ts | 261 -- .../transcript-cost-reader-adapter.ts | 64 - cli/src/contexts/tools/domain/contracts.ts | 16 - .../tools/domain/formats/flat-hooks-merge.ts | 40 - .../tools/domain/host-plugin-registration.ts | 2 +- .../claude/claude-transcript-location.ts | 28 - .../tools/domain/profiles/claude/profile.ts | 10 - .../codex/codex-transcript-location.ts | 13 - .../tools/domain/profiles/codex/profile.ts | 10 - .../tools/domain/profiles/copilot/profile.ts | 15 - .../tools/domain/profiles/cursor/profile.ts | 6 - .../tools/domain/profiles/kilo/profile.ts | 5 - .../opencode/opencode-hooks-bridge.ts | 15 +- .../tools/domain/profiles/opencode/profile.ts | 18 - cli/src/contexts/tools/domain/registry.ts | 11 - cli/src/kernel/describe-error.ts | 10 - cli/src/kernel/errors.ts | 101 - cli/src/kernel/measurement.ts | 53 - cli/src/kernel/paths.ts | 21 +- cli/src/kernel/ports/file-writer.ts | 1 - cli/src/kernel/reading/confined-file-name.ts | 10 - cli/src/kernel/reading/home-dir.ts | 18 +- cli/src/kernel/reading/json-file.ts | 9 - cli/src/kernel/reading/repository-root.ts | 16 - cli/src/presentation/commands/telemetry.ts | 317 -- .../display/cost-report-artefact.ts | 451 -- .../display/cost-report-display.ts | 521 --- .../display/telemetry-check-display.ts | 265 -- .../presentation/display/telemetry-display.ts | 201 - .../display/telemetry-forget-display.ts | 131 - cli/src/runtime/filesystem/file-adapter.ts | 16 +- cli/src/runtime/git/git-adapter.ts | 380 -- cli/src/runtime/wiring/framework.ts | 15 +- .../wiring/installed-plugins-from-manifest.ts | 29 - cli/src/runtime/wiring/telemetry.ts | 137 - .../catches-that-swallow.arch.test.ts | 4 - .../context-boundary.arch.test.ts | 34 +- .../context-self-reentry.arch.test.ts | 4 +- .../architecture/folder-size.arch.test.ts | 19 +- cli/tests/architecture/helpers.ts | 3 - .../orchestrator-deps.arch.test.ts | 4 - .../tool-addition-cost.arch.test.ts | 13 - ...adapter-commit-trailer.integration.test.ts | 313 -- ...t-commit-trailer-setup.integration.test.ts | 294 -- .../application/clean-use-case.unit.test.ts | 5 +- .../contexts/framework/application/helpers.ts | 14 - ...ost-install-pipeline-use-case.unit.test.ts | 6 +- .../restore-all-plugins-use-case.unit.test.ts | 1 - .../plugin-asset-translation.unit.test.ts | 44 +- .../diagnose-telemetry-use-case.unit.test.ts | 1020 ----- .../forget-telemetry-use-case.unit.test.ts | 337 -- .../person-identity-use-case.unit.test.ts | 536 --- .../read-local-cost-sweep.unit.test.ts | 526 --- .../read-local-cost-use-case.unit.test.ts | 1636 -------- .../report-cost-use-case.unit.test.ts | 1181 ------ .../telemetry-off-use-case.unit.test.ts | 284 -- ...emetry-on-hook-manager.integration.test.ts | 102 - .../telemetry-on-use-case.unit.test.ts | 377 -- .../application/tool-attribution.unit.test.ts | 93 - .../domain/cost-report-backlog.unit.test.ts | 244 -- .../domain/cost-report-contract.unit.test.ts | 79 - .../domain/cost-report-envelope.unit.test.ts | 598 --- .../cost-report-order.property.unit.test.ts | 162 - .../domain/cost-report-person.unit.test.ts | 319 -- .../domain/cost-report-task.unit.test.ts | 448 -- .../telemetry/domain/cost-report.unit.test.ts | 1840 -------- .../domain/flow-attribution.unit.test.ts | 340 -- ...claude-code-transcript-fields.unit.test.ts | 198 - .../claude-code-transcript.unit.test.ts | 450 -- .../domain/formats/codex-rollout.unit.test.ts | 393 -- ...commit-session-trailer.integration.test.ts | 74 - .../commit-session-trailer.unit.test.ts | 150 - .../formats/copilot-events.unit.test.ts | 176 - ...local-cost-fixtures.redaction.unit.test.ts | 89 - .../formats/opencode-export.unit.test.ts | 214 - .../domain/journal-intervals.unit.test.ts | 140 - .../domain/metrics-contract.unit.test.ts | 171 - .../domain/person-resolution.unit.test.ts | 204 - .../domain/report-period.unit.test.ts | 127 - .../domain/report/axes/day-rows.unit.test.ts | 17 - .../domain/report/axes/flow-rows.unit.test.ts | 93 - .../report/axes/person-rows.unit.test.ts | 120 - .../axes/record-stated-rows.unit.test.ts | 80 - .../domain/report/axes/step-rows.unit.test.ts | 58 - .../domain/report/axes/task-rows.unit.test.ts | 198 - .../domain/report/axes/tool-rows.unit.test.ts | 27 - .../report/record-reconciliation.unit.test.ts | 163 - .../report/report-selection.unit.test.ts | 265 -- .../domain/report/row-ordering.unit.test.ts | 66 - .../domain/session-anchor.unit.test.ts | 18 - .../domain/session-project.unit.test.ts | 187 - .../telemetry/domain/skill-name.unit.test.ts | 35 - .../domain/step-attribution.unit.test.ts | 475 --- .../domain/task-attribution.unit.test.ts | 312 -- .../domain/task-backlog-link.unit.test.ts | 15 - .../domain/task-identity.unit.test.ts | 131 - .../telemetry-claim-details.unit.test.ts | 454 -- .../domain/telemetry-claim.unit.test.ts | 617 --- .../telemetry-export-leftover.unit.test.ts | 43 - .../domain/telemetry-removal.unit.test.ts | 76 - .../domain/telemetry-setup.unit.test.ts | 96 - .../domain/telemetry-sink-record.unit.test.ts | 157 - .../telemetry-sink-retention.unit.test.ts | 39 - .../domain/telemetry-switch.unit.test.ts | 200 - ...ot-cost-reader-adapter.integration.test.ts | 43 - ...r-telemetry-project-id.integration.test.ts | 40 - ...k-trust-reader-adapter.integration.test.ts | 131 - ...de-cost-reader-adapter.integration.test.ts | 243 -- ...erson-identity-adapter.integration.test.ts | 368 -- .../person-identity-location.unit.test.ts | 79 - ...n-journal-file-written.integration.test.ts | 101 - ...journal-reader-adapter.integration.test.ts | 558 --- ...n-journal-reader-lines.integration.test.ts | 312 -- ...-journal-task-declared.integration.test.ts | 103 - .../task-backlog-adapter.integration.test.ts | 245 -- ...sk-backlog-skill-shape.integration.test.ts | 90 - ...metry-evidence-adapter.integration.test.ts | 543 --- ...telemetry-sink-adapter.integration.test.ts | 442 -- .../telemetry-sink-location.unit.test.ts | 335 -- ...metry-sink-windows-acl.integration.test.ts | 134 - ...pt-cost-reader-adapter.integration.test.ts | 115 - .../formats/flat-hooks-merge.unit.test.ts | 75 - .../codex-transcript-location.unit.test.ts | 11 - .../profiles/opencode/build.unit.test.ts | 22 - .../opencode-hooks-bridge.unit.test.ts | 15 +- .../domain/registry-conformance.unit.test.ts | 100 - .../tools/domain/registry.unit.test.ts | 2 - .../telemetry-route-supply.unit.test.ts | 154 - .../tools/domain/tool-config.unit.test.ts | 2 - cli/tests/e2e/helpers.ts | 29 - ...sandbox-reaches-no-tool-binary.e2e.test.ts | 2 +- .../e2e/telemetry-backlog-axis.e2e.test.ts | 227 - ...telemetry-check-skill-commands.e2e.test.ts | 66 - cli/tests/e2e/telemetry-check.e2e.test.ts | 670 --- .../e2e/telemetry-commit-trailer.e2e.test.ts | 179 - .../telemetry-cost-skill-commands.e2e.test.ts | 104 - cli/tests/e2e/telemetry-flow-axis.e2e.test.ts | 244 -- cli/tests/e2e/telemetry-forget.e2e.test.ts | 361 -- .../e2e/telemetry-hook-install.e2e.test.ts | 80 - .../telemetry-host-registration.e2e.test.ts | 117 - .../telemetry-identity-resolution.e2e.test.ts | 298 -- cli/tests/e2e/telemetry-identity.e2e.test.ts | 452 -- .../telemetry-init-skill-commands.e2e.test.ts | 132 - .../telemetry-journal-gitignore.e2e.test.ts | 89 - cli/tests/e2e/telemetry-lifecycle.e2e.test.ts | 240 -- .../e2e/telemetry-multi-tool.e2e.test.ts | 461 -- .../e2e/telemetry-on-runs-privacy.e2e.test.ts | 167 - .../telemetry-plugin-standalone.e2e.test.ts | 178 - .../e2e/telemetry-reference-week.e2e.test.ts | 270 -- cli/tests/e2e/telemetry-refusal.e2e.test.ts | 172 - cli/tests/e2e/telemetry-report.e2e.test.ts | 316 -- .../e2e/telemetry-six-questions.e2e.test.ts | 198 - ...telemetry-stored-export-record.e2e.test.ts | 125 - .../e2e/telemetry-task-midsession.e2e.test.ts | 271 -- cli/tests/e2e/telemetry.e2e.test.ts | 91 - .../cli-owns-read/expected-envelope.json | 544 --- .../cli-owns-read/telemetry/2026-01-05.jsonl | 2 - .../cli-owns-read/telemetry/2026-01-06.jsonl | 2 - .../cli-owns-read/telemetry/2026-01-07.jsonl | 1 - ...22222222-2222-4222-8222-222222222222.jsonl | 8 - .../subagents/agent-aa81cdef3bb58820c.jsonl | 1 - ...019f69d0-9e1f-7951-86c9-ddb23cfd51f4.jsonl | 4 - ...019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl | 8 - .../events.jsonl | 2 - .../events.jsonl | 2 - .../events.jsonl | 2 - .../fixtures/telemetry-sink/expected.jsonl | 3 - .../opencode-export-non-anthropic-cache.json | 155 - .../telemetry-sink/opencode-export.json | 224 - .../snapshots/framework-build/golden.json | 2 +- cli/tests/golden/snapshots/help/surface.json | 62 +- .../helpers/ports/in-memory-file-adapter.ts | 4 - .../ports/in-memory-person-identity-reader.ts | 22 - .../ports/in-memory-person-identity-store.ts | 85 - .../ports/in-memory-run-journal-reader.ts | 59 - .../ports/in-memory-task-backlog-reader.ts | 16 - .../helpers/ports/in-memory-telemetry-sink.ts | 81 - cli/tests/helpers/ports/stub-ai-tool.ts | 2 - .../ports/stub-telemetry-evidence-reader.ts | 46 - cli/tests/helpers/telemetry-journal-hook.ts | 89 - .../helpers/throwaway-profile.unit.test.ts | 4 +- ...ry-trailer-line-agrees.integration.test.ts | 88 - cli/tests/kernel/describe-error.unit.test.ts | 12 +- cli/tests/kernel/errors.unit.test.ts | 67 - .../reading/aidd-config-dir.unit.test.ts | 12 - .../reading/confined-file-name.unit.test.ts | 32 - .../kernel/reading/json-file.unit.test.ts | 21 +- .../telemetry-wiring.integration.test.ts | 622 --- .../display/cost-report-artefact.unit.test.ts | 1040 ----- .../display/cost-report-display.unit.test.ts | 988 ----- .../telemetry-check-display.unit.test.ts | 868 ---- .../display/telemetry-display.unit.test.ts | 817 ---- .../telemetry-forget-display.unit.test.ts | 228 - .../file-adapter-edges.integration.test.ts | 11 - .../git/git-adapter.integration.test.ts | 433 -- .../check-update-use-case.unit.test.ts | 1 - .../self-update/check-update.unit.test.ts | 1 - ...stalled-plugins-from-manifest.unit.test.ts | 48 - docs/ARCHITECTURE.md | 15 +- docs/CATALOG.md | 13 +- docs/FAQ.md | 27 +- docs/MAINTAINERS.md | 1 - docs/prompts-documentation.md | 50 - lefthook.yml | 20 - package.json | 3 +- plugins/aidd-dev/skills/01-plan/SKILL.md | 12 - .../skills/01-plan/actions/04-plan.md | 13 +- .../skills/00-async-dev/SKILL.md | 13 - .../aidd-orchestrator/skills/01-sdlc/SKILL.md | 13 - .../skills/01-sdlc/references/01-frame.md | 2 - .../skills/02-backlog/SKILL.md | 13 - plugins/aidd-pm/CATALOG.md | 2 - .../skills/04-spec/actions/01-build.md | 4 +- .../04-spec/assets/backlog-link-template.json | 5 - .../skills/04-spec/references/backlog-link.md | 12 - .../aidd-telemetry/.claude-plugin/plugin.json | 6 +- plugins/aidd-telemetry/CATALOG.md | 42 - plugins/aidd-telemetry/README.md | 98 +- plugins/aidd-telemetry/hooks/hooks.json | 34 - plugins/aidd-telemetry/hooks/journal.cjs | 109 - .../aidd-telemetry/hooks/lib/file-writes.cjs | 199 - plugins/aidd-telemetry/hooks/lib/host.cjs | 68 - .../hooks/lib/plugin-version.cjs | 96 - plugins/aidd-telemetry/hooks/lib/record.cjs | 297 -- plugins/aidd-telemetry/hooks/lib/repo.cjs | 302 -- .../aidd-telemetry/hooks/lib/step-ends.cjs | 78 - .../aidd-telemetry/hooks/lib/step-starts.cjs | 45 - .../hooks/lib/task-declared.cjs | 83 - .../hooks/lib/tools/claude-code.cjs | 38 - .../aidd-telemetry/hooks/lib/tools/codex.cjs | 48 - .../hooks/lib/tools/copilot.cjs | 37 - .../aidd-telemetry/hooks/lib/tools/cursor.cjs | 29 - .../aidd-telemetry/hooks/lib/tools/index.cjs | 35 - .../hooks/lib/tools/opencode.cjs | 13 - .../hooks/lib/tools/skill-detection.cjs | 62 - .../hooks/lib/trailer-repair.cjs | 209 - .../aidd-telemetry/hooks/opencode-plugin.js | 157 - .../aidd-telemetry/skills/00-init/SKILL.md | 57 - .../skills/00-init/actions/01-check.md | 44 - .../skills/00-init/actions/02-enable.md | 32 - .../skills/00-init/actions/03-verify.md | 27 - .../skills/00-init/actions/04-identify.md | 69 - .../skills/00-init/actions/05-forget.md | 65 - .../aidd-telemetry/skills/01-cost/SKILL.md | 52 - .../skills/01-cost/actions/01-locate.md | 39 - .../skills/01-cost/actions/02-collect.md | 35 - .../skills/01-cost/actions/03-report.md | 192 - .../aidd-telemetry/skills/02-check/SKILL.md | 35 - .../skills/02-check/actions/01-locate.md | 37 - .../skills/02-check/actions/02-diagnose.md | 110 - ...a-backlog-link-the-reader-can-read.test.js | 129 - .../aidd-orchestrator-no-host-reports.test.js | 42 - .../aidd-telemetry-cost-skill.test.js | 384 -- .../aidd-telemetry-file-writes.test.js | 232 -- .../aidd-telemetry-journal-perf-harness.js | 126 - .../__tests__/aidd-telemetry-journal.test.js | 3704 ----------------- .../__tests__/aidd-telemetry-manifest.test.js | 19 - .../aidd-telemetry-opencode-payloads.test.js | 345 -- .../aidd-telemetry-plugin-version.test.js | 316 -- .../__tests__/aidd-telemetry-runs-dir.test.js | 106 - .../__tests__/aidd-telemetry-step-end.test.js | 127 - .../aidd-telemetry-task-declaration.test.js | 78 - ...telemetry-task-path-pattern-parity.test.js | 35 - .../aidd-telemetry-trailer-repair.test.js | 459 -- scripts/__tests__/architecture-rules.test.js | 2 +- .../comments-name-files-that-exist.test.js | 80 - scripts/__tests__/fixtures/README.md | 378 +- .../claude-code-post-tool-use-bash.json | 11 - .../claude-code-post-tool-use-edit.json | 13 - ...aude-code-post-tool-use-notebook-edit.json | 14 - .../claude-code-post-tool-use-skill.json | 21 - .../claude-code-post-tool-use-write.json | 11 - .../fixtures/claude-code-session-start.json | 7 - .../fixtures/claude-code-task-declared.json | 27 - .../codex-post-tool-use-skill-read.json | 15 - .../fixtures/codex-post-tool-use.json | 15 - .../__tests__/fixtures/codex-session-end.json | 7 - .../fixtures/codex-session-start.json | 9 - .../fixtures/codex-task-declared.json | 15 - .../copilot-compat-post-tool-use-skill.json | 14 - .../copilot-compat-post-tool-use.json | 15 - .../copilot-compat-pre-tool-use-skill.json | 10 - .../copilot-compat-session-start.json | 8 - .../fixtures/copilot-compat-turn-end.json | 9 - .../copilot-compat-user-prompt-submitted.json | 7 - .../fixtures/copilot-post-tool-use-skill.json | 11 - .../fixtures/copilot-session-start.json | 7 - .../fixtures/copilot-task-declared.json | 14 - .../cursor-post-tool-use-skill-read.json | 16 - .../fixtures/cursor-post-tool-use.json | 20 - .../fixtures/cursor-session-start.json | 14 - .../fixtures/cursor-task-declared.json | 18 - .../fixtures/opencode-session-created.json | 19 - .../fixtures/opencode-task-declared.json | 8 - .../fixtures/opencode-tool-part-no-task.json | 30 - scripts/__tests__/opencode-plugin.test.js | 321 -- .../__tests__/plugin-install-shape.test.js | 36 - .../__tests__/telemetry-cli-required.test.js | 79 - .../telemetry-where-things-live.test.js | 130 - scripts/check-claude-accepts-build.cjs | 2 +- scripts/check-doc-duplication.js | 5 +- scripts/lib/frozen-clock.cjs | 24 - scripts/lib/telemetry-reference-week.cjs | 520 --- scripts/probe-identifier-join.cjs | 242 -- scripts/telemetry-reference-week.cjs | 82 - 420 files changed, 216 insertions(+), 58699 deletions(-) delete mode 100644 aidd_docs/product/cost-report-contract.md delete mode 100644 aidd_docs/product/metrics-contract.md delete mode 100644 aidd_docs/runs/.gitkeep delete mode 100644 aidd_docs/runs/README.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md delete mode 100644 cli/.claude/skills/telemetry/SKILL.md delete mode 100644 cli/aidd_docs/memory/telemetry.md delete mode 100644 cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts delete mode 100644 cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts delete mode 100644 cli/src/contexts/telemetry/application/person-identity-use-case.ts delete mode 100644 cli/src/contexts/telemetry/application/read-local-cost-use-case.ts delete mode 100644 cli/src/contexts/telemetry/application/report-cost-use-case.ts delete mode 100644 cli/src/contexts/telemetry/application/telemetry-off-use-case.ts delete mode 100644 cli/src/contexts/telemetry/application/telemetry-on-use-case.ts delete mode 100644 cli/src/contexts/telemetry/domain/cost-report-envelope.ts delete mode 100644 cli/src/contexts/telemetry/domain/cost-report.ts delete mode 100644 cli/src/contexts/telemetry/domain/flow-attribution.ts delete mode 100644 cli/src/contexts/telemetry/domain/formats/claude-code-transcript.ts delete mode 100644 cli/src/contexts/telemetry/domain/formats/codex-rollout.ts delete mode 100644 cli/src/contexts/telemetry/domain/formats/commit-session-trailer.ts delete mode 100644 cli/src/contexts/telemetry/domain/formats/copilot-events.ts delete mode 100644 cli/src/contexts/telemetry/domain/formats/opencode-export.ts delete mode 100644 cli/src/contexts/telemetry/domain/journal-intervals.ts delete mode 100644 cli/src/contexts/telemetry/domain/person-resolution.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/hook-trust-reader.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/ignore-entries.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/installed-plugins-reader.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/person-identity-reader.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/person-identity-store.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/session-cost-reader.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/task-backlog-reader.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/telemetry-evidence-reader.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/telemetry-sink.ts delete mode 100644 cli/src/contexts/telemetry/domain/ports/version-control.ts delete mode 100644 cli/src/contexts/telemetry/domain/report-period.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/axes/day-rows.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/axes/flow-rows.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/axes/person-rows.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/axes/record-stated-rows.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/axes/step-rows.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/axes/task-rows.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/axes/tool-rows.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/record-counters.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/record-reconciliation.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/report-selection.ts delete mode 100644 cli/src/contexts/telemetry/domain/report/row-ordering.ts delete mode 100644 cli/src/contexts/telemetry/domain/session-anchor.ts delete mode 100644 cli/src/contexts/telemetry/domain/session-project.ts delete mode 100644 cli/src/contexts/telemetry/domain/skill-name.ts delete mode 100644 cli/src/contexts/telemetry/domain/step-attribution.ts delete mode 100644 cli/src/contexts/telemetry/domain/task-attribution.ts delete mode 100644 cli/src/contexts/telemetry/domain/task-backlog-link.ts delete mode 100644 cli/src/contexts/telemetry/domain/task-identity.ts delete mode 100644 cli/src/contexts/telemetry/domain/telemetry-claim.ts delete mode 100644 cli/src/contexts/telemetry/domain/telemetry-export-leftover.ts delete mode 100644 cli/src/contexts/telemetry/domain/telemetry-removal.ts delete mode 100644 cli/src/contexts/telemetry/domain/telemetry-setup.ts delete mode 100644 cli/src/contexts/telemetry/domain/telemetry-sink-record.ts delete mode 100644 cli/src/contexts/telemetry/domain/telemetry-sink-retention.ts delete mode 100644 cli/src/contexts/telemetry/domain/telemetry-switch.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts delete mode 100644 cli/src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.ts delete mode 100644 cli/src/contexts/tools/domain/profiles/claude/claude-transcript-location.ts delete mode 100644 cli/src/contexts/tools/domain/profiles/codex/codex-transcript-location.ts delete mode 100644 cli/src/kernel/measurement.ts delete mode 100644 cli/src/kernel/reading/confined-file-name.ts delete mode 100644 cli/src/kernel/reading/repository-root.ts delete mode 100644 cli/src/presentation/commands/telemetry.ts delete mode 100644 cli/src/presentation/display/cost-report-artefact.ts delete mode 100644 cli/src/presentation/display/cost-report-display.ts delete mode 100644 cli/src/presentation/display/telemetry-check-display.ts delete mode 100644 cli/src/presentation/display/telemetry-display.ts delete mode 100644 cli/src/presentation/display/telemetry-forget-display.ts delete mode 100644 cli/src/runtime/git/git-adapter.ts delete mode 100644 cli/src/runtime/wiring/installed-plugins-from-manifest.ts delete mode 100644 cli/src/runtime/wiring/telemetry.ts delete mode 100644 cli/tests/contexts/distribution/infrastructure/git-adapter-commit-trailer.integration.test.ts delete mode 100644 cli/tests/contexts/distribution/infrastructure/git-commit-trailer-setup.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/telemetry-on-hook-manager.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/cost-report-backlog.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/cost-report-contract.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/cost-report-order.property.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/cost-report-person.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/cost-report-task.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/formats/claude-code-transcript.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/formats/local-cost-fixtures.redaction.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report-period.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/session-project.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/task-backlog-link.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/telemetry-setup.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/telemetry-sink-retention.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/git-adapter-telemetry-project-id.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/person-identity-location.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/task-backlog-skill-shape.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts delete mode 100644 cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts delete mode 100644 cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts delete mode 100644 cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts delete mode 100644 cli/tests/e2e/telemetry-backlog-axis.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-check.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-commit-trailer.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-flow-axis.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-forget.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-hook-install.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-host-registration.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-identity-resolution.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-identity.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-lifecycle.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-multi-tool.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-reference-week.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-refusal.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-report.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-six-questions.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-stored-export-record.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry-task-midsession.e2e.test.ts delete mode 100644 cli/tests/e2e/telemetry.e2e.test.ts delete mode 100644 cli/tests/fixtures/cli-owns-read/expected-envelope.json delete mode 100644 cli/tests/fixtures/cli-owns-read/telemetry/2026-01-05.jsonl delete mode 100644 cli/tests/fixtures/cli-owns-read/telemetry/2026-01-06.jsonl delete mode 100644 cli/tests/fixtures/cli-owns-read/telemetry/2026-01-07.jsonl delete mode 100644 cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222.jsonl delete mode 100644 cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222/subagents/agent-aa81cdef3bb58820c.jsonl delete mode 100644 cli/tests/fixtures/local-cost/.codex/sessions/2026/07/16/rollout-2026-07-16T09-25-07-019f69d0-9e1f-7951-86c9-ddb23cfd51f4.jsonl delete mode 100644 cli/tests/fixtures/local-cost/.codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl delete mode 100644 cli/tests/fixtures/local-cost/.copilot/session-state/33333333-3333-4333-8333-333333333333/events.jsonl delete mode 100644 cli/tests/fixtures/local-cost/.copilot/session-state/44444444-4444-4444-8444-444444444444/events.jsonl delete mode 100644 cli/tests/fixtures/local-cost/.copilot/session-state/55555555-5555-4555-8555-555555555555/events.jsonl delete mode 100644 cli/tests/fixtures/telemetry-sink/expected.jsonl delete mode 100644 cli/tests/fixtures/telemetry-sink/opencode-export-non-anthropic-cache.json delete mode 100644 cli/tests/fixtures/telemetry-sink/opencode-export.json delete mode 100644 cli/tests/helpers/ports/in-memory-person-identity-reader.ts delete mode 100644 cli/tests/helpers/ports/in-memory-person-identity-store.ts delete mode 100644 cli/tests/helpers/ports/in-memory-run-journal-reader.ts delete mode 100644 cli/tests/helpers/ports/in-memory-task-backlog-reader.ts delete mode 100644 cli/tests/helpers/ports/in-memory-telemetry-sink.ts delete mode 100644 cli/tests/helpers/ports/stub-telemetry-evidence-reader.ts delete mode 100644 cli/tests/helpers/telemetry-journal-hook.ts delete mode 100644 cli/tests/integration/telemetry-trailer-line-agrees.integration.test.ts delete mode 100644 cli/tests/kernel/reading/aidd-config-dir.unit.test.ts delete mode 100644 cli/tests/kernel/reading/confined-file-name.unit.test.ts delete mode 100644 cli/tests/presentation/commands/telemetry-wiring.integration.test.ts delete mode 100644 cli/tests/presentation/display/cost-report-artefact.unit.test.ts delete mode 100644 cli/tests/presentation/display/cost-report-display.unit.test.ts delete mode 100644 cli/tests/presentation/display/telemetry-check-display.unit.test.ts delete mode 100644 cli/tests/presentation/display/telemetry-display.unit.test.ts delete mode 100644 cli/tests/presentation/display/telemetry-forget-display.unit.test.ts delete mode 100644 cli/tests/runtime/git/git-adapter.integration.test.ts delete mode 100644 cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts delete mode 100644 docs/prompts-documentation.md delete mode 100644 plugins/aidd-pm/skills/04-spec/assets/backlog-link-template.json delete mode 100644 plugins/aidd-pm/skills/04-spec/references/backlog-link.md delete mode 100644 plugins/aidd-telemetry/hooks/hooks.json delete mode 100644 plugins/aidd-telemetry/hooks/journal.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/file-writes.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/host.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/plugin-version.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/record.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/repo.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/step-ends.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/step-starts.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/task-declared.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/tools/claude-code.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/tools/codex.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/tools/copilot.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/tools/cursor.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/tools/index.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/tools/skill-detection.cjs delete mode 100644 plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs delete mode 100644 plugins/aidd-telemetry/hooks/opencode-plugin.js delete mode 100644 plugins/aidd-telemetry/skills/00-init/SKILL.md delete mode 100644 plugins/aidd-telemetry/skills/00-init/actions/01-check.md delete mode 100644 plugins/aidd-telemetry/skills/00-init/actions/02-enable.md delete mode 100644 plugins/aidd-telemetry/skills/00-init/actions/03-verify.md delete mode 100644 plugins/aidd-telemetry/skills/00-init/actions/04-identify.md delete mode 100644 plugins/aidd-telemetry/skills/00-init/actions/05-forget.md delete mode 100644 plugins/aidd-telemetry/skills/01-cost/SKILL.md delete mode 100644 plugins/aidd-telemetry/skills/01-cost/actions/01-locate.md delete mode 100644 plugins/aidd-telemetry/skills/01-cost/actions/02-collect.md delete mode 100644 plugins/aidd-telemetry/skills/01-cost/actions/03-report.md delete mode 100644 plugins/aidd-telemetry/skills/02-check/SKILL.md delete mode 100644 plugins/aidd-telemetry/skills/02-check/actions/01-locate.md delete mode 100644 plugins/aidd-telemetry/skills/02-check/actions/02-diagnose.md delete mode 100644 scripts/__tests__/a-backlog-link-the-reader-can-read.test.js delete mode 100644 scripts/__tests__/aidd-orchestrator-no-host-reports.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-cost-skill.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-file-writes.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-journal-perf-harness.js delete mode 100644 scripts/__tests__/aidd-telemetry-journal.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-manifest.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-opencode-payloads.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-plugin-version.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-runs-dir.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-step-end.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-task-declaration.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-task-path-pattern-parity.test.js delete mode 100644 scripts/__tests__/aidd-telemetry-trailer-repair.test.js delete mode 100644 scripts/__tests__/fixtures/claude-code-post-tool-use-bash.json delete mode 100644 scripts/__tests__/fixtures/claude-code-post-tool-use-edit.json delete mode 100644 scripts/__tests__/fixtures/claude-code-post-tool-use-notebook-edit.json delete mode 100644 scripts/__tests__/fixtures/claude-code-post-tool-use-skill.json delete mode 100644 scripts/__tests__/fixtures/claude-code-post-tool-use-write.json delete mode 100644 scripts/__tests__/fixtures/claude-code-session-start.json delete mode 100644 scripts/__tests__/fixtures/claude-code-task-declared.json delete mode 100644 scripts/__tests__/fixtures/codex-post-tool-use-skill-read.json delete mode 100644 scripts/__tests__/fixtures/codex-post-tool-use.json delete mode 100644 scripts/__tests__/fixtures/codex-session-end.json delete mode 100644 scripts/__tests__/fixtures/codex-session-start.json delete mode 100644 scripts/__tests__/fixtures/codex-task-declared.json delete mode 100644 scripts/__tests__/fixtures/copilot-compat-post-tool-use-skill.json delete mode 100644 scripts/__tests__/fixtures/copilot-compat-post-tool-use.json delete mode 100644 scripts/__tests__/fixtures/copilot-compat-pre-tool-use-skill.json delete mode 100644 scripts/__tests__/fixtures/copilot-compat-session-start.json delete mode 100644 scripts/__tests__/fixtures/copilot-compat-turn-end.json delete mode 100644 scripts/__tests__/fixtures/copilot-compat-user-prompt-submitted.json delete mode 100644 scripts/__tests__/fixtures/copilot-post-tool-use-skill.json delete mode 100644 scripts/__tests__/fixtures/copilot-session-start.json delete mode 100644 scripts/__tests__/fixtures/copilot-task-declared.json delete mode 100644 scripts/__tests__/fixtures/cursor-post-tool-use-skill-read.json delete mode 100644 scripts/__tests__/fixtures/cursor-post-tool-use.json delete mode 100644 scripts/__tests__/fixtures/cursor-session-start.json delete mode 100644 scripts/__tests__/fixtures/cursor-task-declared.json delete mode 100644 scripts/__tests__/fixtures/opencode-session-created.json delete mode 100644 scripts/__tests__/fixtures/opencode-task-declared.json delete mode 100644 scripts/__tests__/fixtures/opencode-tool-part-no-task.json delete mode 100644 scripts/__tests__/opencode-plugin.test.js delete mode 100644 scripts/__tests__/plugin-install-shape.test.js delete mode 100644 scripts/__tests__/telemetry-cli-required.test.js delete mode 100644 scripts/__tests__/telemetry-where-things-live.test.js delete mode 100644 scripts/lib/frozen-clock.cjs delete mode 100644 scripts/lib/telemetry-reference-week.cjs delete mode 100755 scripts/probe-identifier-join.cjs delete mode 100644 scripts/telemetry-reference-week.cjs diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 072eb4439..2e8291f5d 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -45,8 +45,7 @@ jobs: id: filter run: | set -euo pipefail - # The scope the jobs below actually exercise. The telemetry plugin's own prose is - # excluded because validate.yml already asserts it on every push and pull request. + # The scope the jobs below actually exercise. if [[ "${{ github.event_name }}" == "pull_request" ]]; then BASE="${{ github.event.pull_request.base.sha }}" HEAD="${{ github.event.pull_request.head.sha }}" @@ -75,12 +74,6 @@ jobs: .github/workflows/cli-ci.yml) relevant=true ;; - plugins/aidd-telemetry/*) - case "$f" in - *.md) ;; # prose only, covered by validate.yml instead - *) relevant=true ;; - esac - ;; esac [[ "$relevant" == "true" ]] && break done < <(git diff --name-only "$BASE" "$HEAD") @@ -407,15 +400,9 @@ jobs: restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - run: cd cli && pnpm build - # The probe drives the real tool, so the real tool has to be here. No credentials are - # needed: it points Claude Code at a dead address, and the session id is minted first. + # The check drives the real tool, so the real tool has to be here. No credentials are needed. - name: Install Claude Code run: npm install -g @anthropic-ai/claude-code # floats on purpose, declared in scripts/__tests__/workflows-pin-global-installs.test.js - # Everything in this layer joins on the identifier a hook receives being the one the - # export carries, and a tool update can break it with nothing else turning red. Exit 2 - # means the probe could not form an opinion, rather than blaming the tool. - - name: Probe the identifier join - run: node scripts/probe-identifier-join.cjs # The golden snapshots pin what a claude build contains; only the host can say it loads. # Its `plugin validate` exits 0 either way, so the script reads the verdict from its text. - name: Claude Code accepts the translated marketplace @@ -484,38 +471,9 @@ jobs: with: node-version: "22" - # The WRITE path, on the platform where a home directory, a line ending and an absolute - # path all resolve differently. It runs before the CLI is built on purpose: recording is - # supposed to need nothing installed, and this is where that claim is exercised. - - name: Chain - allow measurement, with nothing installed - run: | - # Written directly rather than through a command: the switch is a file the hooks - # read fresh at every write, so no binary has to exist for it to work. - mkdir -p .aidd - node -e "require('fs').writeFileSync('.aidd/config.json', JSON.stringify({ telemetry: { enabled: true } }, null, 2) + '\n');" - - name: Chain - journal a captured payload - run: | - # The fixture's cwd came off whatever machine captured it: rewritten so getRepoRoot - # resolves a repository that exists here. - node -e "const p=require('./scripts/__tests__/fixtures/claude-code-session-start.json'); p.cwd=process.cwd(); require('fs').writeFileSync('payload.json', JSON.stringify(p));" - node plugins/aidd-telemetry/hooks/journal.cjs session-start < payload.json - node plugins/aidd-telemetry/hooks/journal.cjs turn-end < payload.json - # `telemetry check` fails on `sessionFound: false`, and nothing in this chain places a - # transcript for the session just journalled. Copied from a local-cost fixture with its - # session id rewritten, under `$HOME` — which is what the read path itself resolves - # first, and which Git Bash sets on this runner. - - name: Chain - place the transcript where claude keeps it - run: | - node -e "const fs=require('fs'),os=require('os'),path=require('path');const homeDir=process.env.HOME||os.homedir();const sessionId='ffde6fda-14a8-4b32-8110-be1f1d13eebf';const src='cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222.jsonl';const dir=path.join(homeDir,'.claude','projects','fake-project');fs.mkdirSync(dir,{recursive:true});const content=fs.readFileSync(src,'utf8').split('22222222-2222-4222-8222-222222222222').join(sessionId);fs.writeFileSync(path.join(dir,sessionId+'.jsonl'),content);" - - name: Chain - the journal stays private and git add -A still works - run: | - git add -A - git status --porcelain >/dev/null - # Wrapped: a suite writing into this repository's own .git/hooks passes every assertion # and destroys an install nothing can restore. The suite reads YAML through the root - # devDependencies, so the install has to precede it — and the chain steps above stay - # ahead of any install on purpose. + # devDependencies, so the install has to precede it. - run: pnpm install --frozen-lockfile - name: Plugin suite run: node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js' @@ -534,20 +492,12 @@ jobs: pnpm build pnpm pack --pack-destination ./dist npm install -g ./dist/ai-driven-dev-cli-*.tgz --force - # The exact command `02-check`'s own markdown names, not a script beside it. Failing - # `aidd --version` means the CLI could not be resolved on the PATH. - - name: Chain - diagnose, through the command every skill's own markdown names - run: | - aidd --version - # A gated run judges nothing and exits 0, so the one row the chain exists to make - # true is asserted separately: the transcript placed for the session was read. - aidd telemetry check | tee telemetry-check.out - grep -E "tool files readable +ok" telemetry-check.out + # Failing `aidd --version` means the CLI could not be resolved on the PATH. + - name: Chain - the installed CLI resolves + run: aidd --version - run: cd cli && pnpm test:unit - run: cd cli && pnpm test:integration - # Every e2e but the two named below. `telemetry-commit-trailer.e2e.test.ts` is why the - # step matters most: it is the only thing proving the `prepare-commit-msg` hook is - # reachable by the shell Git for Windows ships, which a backslash path is not. + # Every e2e but the one named below. - name: cli e2e # `--max-workers=2`, and only here. Vitest transforms modules on its own main thread # while every worker calls back into it, and that call has a fixed 60 s timeout no @@ -557,11 +507,8 @@ jobs: run: | cd cli pnpm exec vitest run --project=e2e --max-workers=2 \ - --exclude "tests/e2e/persona.e2e.test.ts" \ - --exclude "tests/e2e/telemetry-multi-tool.e2e.test.ts" - # persona.e2e.test.ts needs /usr/bin/expect, which windows-latest does not carry; - # telemetry-multi-tool.e2e.test.ts relies on a shebang, and Windows resolves an - # executable by PATHEXT. + --exclude "tests/e2e/persona.e2e.test.ts" + # persona.e2e.test.ts needs /usr/bin/expect, which windows-latest does not carry. # Fan-in: every job above either ran and passed, or was skipped because `changes` found # nothing relevant. The one check the branch rulesets require — without it a pull request diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 85eb94443..d6c1ab1e8 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -52,11 +52,11 @@ jobs: run: pnpm exec lefthook run pre-commit --all-files --force # The generator hooks `git add` their output and never fail on drift, so a stale - # CATALOG.md, prompts doc or README count passes the step above in silence. Against + # CATALOG.md or README count passes the step above in silence. Against # HEAD, not the index: the generators already staged, so a plain `git diff` is empty. - name: Fail if a generated file drifted from what pre-commit regenerated run: | - if ! git diff --exit-code HEAD -- 'plugins/*/CATALOG.md' docs/prompts-documentation.md README.md; then + if ! git diff --exit-code HEAD -- 'plugins/*/CATALOG.md' README.md; then echo "::error::A generated file is stale. Run 'pnpm exec lefthook run pre-commit --all-files --force' locally and commit the result." exit 1 fi diff --git a/.gitignore b/.gitignore index 8ce3232e3..dd4852527 100644 --- a/.gitignore +++ b/.gitignore @@ -34,20 +34,11 @@ coverage/ # AIDD CLI's own local state (install manifest, auth): machine-local, never # part of the project's own tracked content. config.json is the exception: -# it is the committed telemetry switch (see .aidd/config.json and -# aidd_docs/tasks/2026_08/2026_08_20_telemetry-export-enable/phase-1.md), -# tracked so a fresh clone inherits the project's decision. +# it is the committed project switch (see .aidd/config.json), tracked so a +# fresh clone inherits the project's decision. .aidd/* !.aidd/config.json -# AIDD run-journal records: where they land once .aidd/config.json turns -# telemetry on (see plugins/aidd-telemetry/hooks/journal.js) - this -# directory being committed is a location, not a permission. The records it -# holds never are. -aidd_docs/runs/* -!aidd_docs/runs/.gitkeep -!aidd_docs/runs/README.md - # SpecStory captures (may contain transcripts / secrets) .specstory/ diff --git a/README.md b/README.md index 73252a4fd..7bacc36b5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Unify **engineering teams** around **standardized workflows** and **shared best 🧱 **IDE agnostic** · 🏗️ **Legacy systems** · 🌱 **Token-optimized** · 🇫🇷 **Made in France**

- 9 plugins · 51 skills · 2 agents + 9 plugins · 48 skills · 2 agents

[![Open Source](https://img.shields.io/badge/Open_Source-Yes-yellow?logo=open-source-initiative&logoColor=white)](https://opensource.org/) @@ -315,9 +315,9 @@ UI / UX design — smoke-test only, not ready for use. ### 📈 [aidd-telemetry](plugins/aidd-telemetry/README.md) 🧪 -`3 skills` · **beta** +`0 skills` · **beta** -Answers what a piece of work cost — tokens, models, and which skill spent them. The switch is git-tracked, so it applies to everyone who clones; opt out per person with `AIDD_TELEMETRY=0`. Nothing leaves your machine. +Being rebuilt: the previous measurement is removed, and nothing is recorded meanwhile. diff --git a/aidd_docs/memory/architecture.md b/aidd_docs/memory/architecture.md index 7ba45d316..e87f59eb0 100644 --- a/aidd_docs/memory/architecture.md +++ b/aidd_docs/memory/architecture.md @@ -33,7 +33,7 @@ flowchart LR | Concern decides placement, not existence | a missing capability goes to the plugin whose concern owns it; the caller delegates | | A skill is a router | `SKILL.md` dispatches to actions or protocols; the only place a capability is addressed by name | | Recipe skills discover providers at runtime | by description matching. Only agent permission lists and orchestration references name a provider | -| Observation is its own layer | `aidd-telemetry` journals what a session did; it never reads or writes application source | +| Observation is its own layer | `aidd-telemetry` observes what a session did (being rebuilt); it never reads or writes application source | | A launcher runs an external binary, never embeds it | `kanban` broke that and was unwired. Detail in the CLI bank | The concern-to-plugin taxonomy is canonical in [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md). diff --git a/aidd_docs/memory/cli.md b/aidd_docs/memory/cli.md index 0f7a0dce5..c575b6db2 100644 --- a/aidd_docs/memory/cli.md +++ b/aidd_docs/memory/cli.md @@ -6,9 +6,8 @@ The `aidd` binary, built from `cli/` and published to npm as `@ai-driven-dev/cli ## Commands -- Eleven groups: `setup`, `framework`, `translate`, `plugin`, `marketplace`, `auth`, `sync`, `update`, `doctor`, `clean`, `telemetry`. +- Ten groups: `setup`, `framework`, `translate`, `plugin`, `marketplace`, `auth`, `sync`, `update`, `doctor`, `clean`. - No list here. The surface moves; `aidd --help` is the only reading that stays true. -- `telemetry`'s sink, run journal, record shapes, report axes and per-tool declarations: [`cli/aidd_docs/memory/telemetry.md`](../../cli/aidd_docs/memory/telemetry.md). ## Interface diff --git a/aidd_docs/memory/codebase-map.md b/aidd_docs/memory/codebase-map.md index 5a93a6a1b..229ec0d28 100644 --- a/aidd_docs/memory/codebase-map.md +++ b/aidd_docs/memory/codebase-map.md @@ -38,7 +38,6 @@ flowchart TD | Binary | `cli/src/cli.ts` → `dist/cli.js`, bin name `aidd` | | Workflow | `plugins//skills/-/SKILL.md` | | Memory refresh | `plugins/aidd-context/hooks/update_memory.js`, on `SessionStart` | -| Run journal | `plugins/aidd-telemetry/hooks/journal.cjs`, on `SessionStart`, `Stop`, `PostToolUse` | ## Packages diff --git a/aidd_docs/memory/coding-assertions.md b/aidd_docs/memory/coding-assertions.md index 03baec882..5fffc8dfd 100644 --- a/aidd_docs/memory/coding-assertions.md +++ b/aidd_docs/memory/coding-assertions.md @@ -29,7 +29,7 @@ Never state in a commit message or a report anything not just observed in output | 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, cross-plugin orthogonality and router coherence (`scripts/check-architecture-rules.js`), context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit — see below | | 2 | `pnpm exec commitlint --edit` | the message against `commitlint.config.cjs` | -Same hook regenerates each plugin's `CATALOG.md`, the README counts and `docs/prompts-documentation.md`, and stages them. +Same hook regenerates each plugin's `CATALOG.md`, and the README counts, and stages them. Every `cli` job is globbed on `cli/**`. A change under `kanban/` alone fires no local job; run `cd kanban && pnpm test` by hand. CI does cover it — `cli-ci.yml`'s `kanban-checks`, see `deployment.md`. diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 901f9cdd9..7a65597c2 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -9,7 +9,7 @@ Where the project runs and how it ships: CI/CD, environments, and release. | Workflow | Runs | | --- | --- | | `ci.yml` | commitlint on pull requests and on `main`'s tip, plus the PR title itself — the subject a squash merge uses — then release-please on `main` and the release jobs | -| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose. Mutations skip only for a same-repository numeric `promote/next-to-main-*` snapshot whose `cli / gate` passed in a successful `next` push and whose PR merge tree equals that snapshot with `main` already its ancestor; the resulting `main` push reuses it only for that exact two-parent promotion merge when its tree, associated merged PR, and source snapshot all match. Missing, failed, unreadable, or mismatched Git/API proof keeps normal mutation scopes. All non-mutation checks still run on the current PR merge ref or `main` commit. | +| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself. Mutations skip only for a same-repository numeric `promote/next-to-main-*` snapshot whose `cli / gate` passed in a successful `next` push and whose PR merge tree equals that snapshot with `main` already its ancestor; the resulting `main` push reuses it only for that exact two-parent promotion merge when its tree, associated merged PR, and source snapshot all match. Missing, failed, unreadable, or mismatched Git/API proof keeps normal mutation scopes. All non-mutation checks still run on the current PR merge ref or `main` commit. | | `validate.yml` | plugin and marketplace manifests against their schemas, plus the whole pre-commit over the whole tree | | `codeql.yml` | code scanning | | `promote.yml` | opens the `next` to `main` promote PR, merge auto-merge | diff --git a/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md b/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md index d650edbd4..c3a381aad 100644 --- a/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md +++ b/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md @@ -26,7 +26,7 @@ Issue #660 was opened to resolve this. Its body quotes only the privacy clause, **The standard clause is superseded.** In its place: 1. **There is no single standard transport.** The shipped route reads the files each AI tool already wrote. OpenTelemetry is one shape a destination may speak, not the framework's own. -2. **The local store is the sink.** A directory of JSON Lines under the person's own profile, whose contract is published in `aidd_docs/product/metrics-contract.md`. +2. **The local store is the sink.** A directory of JSON Lines under the person's own profile, whose contract is rewritten with the replacement measurement. 3. **Measurement may reach a hosted destination.** "No SaaS" no longer holds. 4. **The framework exposes; the destination analyses.** Aggregating across people and repositories, saved views, filters and anything pushed to someone belong to a hosted destination. What runs on a machine stays deliberately light: it records, it exposes a documented record, and it answers a small number of direct questions through its skills. This is a boundary, not a staging order — local does not grow into an analytics product while waiting for one. @@ -56,7 +56,7 @@ Issue #660 was opened to resolve this. Its body quotes only the privacy clause, - **`aidd telemetry connect` gets a specification it can be written against**: authenticate to a destination the person names, and bind this machine's identity to an account there. Until now it had a purpose but no boundary. - **The deletion is not reversed.** Nothing here restores a listener or an export writer. What ships today opens no port and sends nothing; a destination is the next thing to build, deliberately, with its own consent. - **An amount in currency still has no route.** That is #654's price table, unaffected either way by this decision. -- **The local report is already past "light", and this record says so rather than pretending otherwise.** It carried seven axes when this decision was written; read `ARTEFACT_AXES` in `cli/src/presentation/display/cost-report-artefact.ts` for the current list rather than a count here, which only decays as the surface grows. Nothing here requires removing any of them; what it does require is that growth stops happening for free. A new axis needs an argument for why a machine must answer that question, not merely that it could. +- **The local report is already past "light", and this record says so rather than pretending otherwise.** It carried seven axes when this decision was written; read `ARTEFACT_AXES` in the previous cost report display (removed with the previous telemetry; see git history) for the list as it stood rather than a count here, which only decays as the surface grows. Nothing here requires removing any of them; what it does require is that growth stops happening for free. A new axis needs an argument for why a machine must answer that question, not merely that it could. - **#720's task axis already shipped** (`task` is in `ARTEFACT_AXES` today, alongside others this record did not anticipate — `agent`, `prompt`, `backlog`, `flow`). Under clause 4 that is a destination's question unless someone can say why a single machine must answer it. This record's caution did not gate that landing; reconciling the two is for whichever issue re-litigates this boundary, not a silent rewrite here. - **#656 lands squarely on the destination side.** Per person, per team, per epic, across repositories — that is the analysis this boundary assigns to a hosted destination, and it should be re-scoped there rather than pursued as local work. - **What the framework owes in exchange is a clean exposure.** The record contract, the sink's shape and their versioning stop being internal documentation and become the interface a destination is written against. They now deserve the care an API gets. diff --git a/aidd_docs/memory/project-brief.md b/aidd_docs/memory/project-brief.md index 80099b5b7..bbcd26d21 100644 --- a/aidd_docs/memory/project-brief.md +++ b/aidd_docs/memory/project-brief.md @@ -43,4 +43,3 @@ What this project is, the problem it solves, and its domain language. The non-de | Acceptance QA evidence | `aidd-qa:01-acceptance-qa` | | Refine input and output | `aidd-refine` — brainstorm, challenge, blind spots | | End-to-end orchestration | `aidd-orchestrator:01-sdlc` | -| Measure what a session cost | `aidd-telemetry`, opt-in, plus `aidd telemetry` | diff --git a/aidd_docs/product/cost-report-contract.md b/aidd_docs/product/cost-report-contract.md deleted file mode 100644 index 52b8bf629..000000000 --- a/aidd_docs/product/cost-report-contract.md +++ /dev/null @@ -1,694 +0,0 @@ -# Cost report contract - -**Read this if you are writing a skill, or anything else that reports on AIDD work.** -It describes what `aidd telemetry report --json` prints: one object, the same shape -whatever tool did the work, carrying both the figures and a statement of what each tool -could and could not supply. - -> If instead you are building a **pricing service or an aggregator** that consumes stored -> records directly, read [`metrics-contract.md`](./metrics-contract.md) — the contract for -> one stored line. The two are deliberately different audiences, and picking the wrong one -> is expensive: the record contract makes you responsible for the three double-count rules -> (the two record kinds, a local re-read — including correcting, never summing, a still-open -> turn a later read completes — and one billed call once seen by both an export and a local -> read at once, for a stored line old enough to predate the export route's removal), and -> re-read deduplication. This one has already applied all four. - -**Never reconstruct these figures from stored records.** One computation in one place is -the whole point: two ways of computing a number is how they start disagreeing. - -## Getting the object - -```bash -aidd telemetry report --json -aidd telemetry report --from 2026-08-01 --to 2026-08-31 --json -aidd telemetry report --task 2026_08/2026_08_21_cost-reporter --json -aidd telemetry report --project acme/widgets --step aidd-dev:02-implement --json -``` - -Prints one JSON object on stdout and exits `0`, including when the period holds nothing. -A period that is not a period — `--from notaday`, `--days 0` — exits `1` naming the flag. -A filter naming a value nothing ever recorded still exits `0` — see **Filters** below; -only a malformed period is a usage error. - -## Filters - -Six dimensions exist: day, project, task, step, model, tool — an axis says how to -*group*, a filter says what to *keep*, and every one of them works as either. The period -(`--from`/`--to`/`--days`) is the day filter; `--task` already existed; `--project`, -`--step`, `--model` and `--tool` are the other four, each optional: - -```bash -aidd telemetry report --project acme/widgets --json # this project, whole period -aidd telemetry report --project acme/widgets --step aidd-dev:02-implement --json -``` - -**Filters compose by `and`, never by a query language.** Two given narrow to their -intersection; there is no `or` and no parentheses — the moment a report needs one it has -stopped being a report. Filtering and grouping on the same dimension (`--project X` next -to a `by_project` breakdown that then holds one row) is a legal, boring answer, not an -error. - -**"Axis" above means a breakdown, not a flag.** Every `by_*` array is always present in -the `--json` object, whatever filters were given — grouping by any of the six dimensions -needs no separate flag; reading the matching array is the axis. `by_task` groups by the -same declared intervals `--task` filters on, never by a session's whole-session -written-file inference — see **`by_task`** below. `by_person` is a seventh axis with no -matching filter flag at all — see **`by_person`** below — grouping by who ran the work, -never a way to keep only one person's records. `by_backlog` is an eighth, also with no -filter of its own — see **`by_backlog`** below — regrouping `by_task`'s own rows one level -up, by what each task's folder declares. `by_flow` is a ninth, also with no filter of its -own — see **`by_flow`** below — grouping by which orchestrated run the journal's own step -sequence names, never a second capture. `by_agent` is a tenth, also with no filter of its -own — see **`by_agent`** below — grouping by which agent ran, the main thread carrying its -own row rather than an absence, and a tool that names no agent at all carrying a third. `by_prompt` is an eleventh, also with no filter of its own — -see **`by_prompt`** below — grouping by the prompt that caused the work, the one breakdown -no host limit can leave empty. `aidd telemetry report` also takes -`--axis ` (`total`, `day`, `step`, `model`, `agent`, `prompt`, `task`, `backlog`, -`flow`, `tool`, `project` or `person`), which picks one of those arrays and renders it alone as a small -pasteable artefact instead of the whole object — a convenience for copying one figure out, -not a second way to group. Every figure `--axis` can show is already in the plain `--json` -object; only the one-artefact-at-a-time rendering is what it adds. A name outside the twelve -is a usage error naming the valid list (`Error: Unknown axis 'bogus'. Expected one of: -total, day, step, model, agent, prompt, task, backlog, flow, tool, project, person.`, exit -`1`), not a silently empty artefact. Given both flags at once, `--json` wins and `--axis` is -ignored, never the reverse. - -**A filter matching nothing names itself**, in `empty_selection`, rather than the object -quietly reporting the same shape a genuinely idle period would: - -```jsonc -"empty_selection": { "filter": "project", "value": "acme/ghost", "known": false } -``` - -`known: false` means no record this call could see ever carried that value — a project -nobody ever worked in. `known: true` means the value is real, just idle in this selection; -an optional `"combination": true` alongside it means the value matches something on its -own, and it is the intersection with an already-applied filter that emptied the -selection, not the value itself. `empty_selection` is **never** present for a period that -is genuinely idle — that case is a row of zeros, because the zero is true; this field -exists only for the different case, where a filter is what emptied it. - -The known/unknown distinction is only as good as what a call could still see: a value -whose every record has since rotated out of the sink reads as `known: false`, the same as -one that never existed. It answers "did anything I can still read ever carry this", not -"did this ever happen". - -**A model filter always drops a whole-session figure; a step filter usually does.** -`active_time_s` and a tool's `session_totals` come from `kind: "session"` records, and -those never carry a `model` — no reader stamps one on a session-kind record, on any tool -measured so far. Filtering by model is correct to exclude them: a model selection cannot -speak to a whole-session figure no model was ever attached to. A `step` is different: a -session record still gets one wherever its own moment happens to fall inside a journal's -`step_start` interval, the same attribution every other record gets — so a step filter -keeps a session record when that interval matches, and drops it otherwise. Either way the -number does not appear as `0` when dropped; it is simply absent, the same convention every -other "never observed" quantity in this object uses. - -Adding `filters` and `empty_selection` was not a `cost_report_version` bump: a consumer -built against version 2 that never passes a filter never sees either field, and neither -changes what any field it already understood means. - -## Determinism - -**The same files and the same absolute period produce byte-identical output.** That holds -across repeated calls and across the order records happen to sit in on disk, which differs -between machines because a re-read appends. - -It does **not** hold for `--days`, which resolves against today. `--days` is the human -shorthand; anything that stores or compares a figure should ask for `--from` and `--to`. -The object always reports the period **as it resolved**, absolutely, never as it was asked -for — so a figure taken from a `--days` call can still be cited by the days it covered. - -## Versioning - -`measurement_enabled` was added without a bump: a consumer built against version 4 that -never reads it sees every field it already understood mean exactly what it already meant — -"adding a field you may ignore" is the rule below, applied to the case that motivated -writing it down. It says whether this project's own switch is on right now; the sink the -figures above come from is scoped to this person, not to this project, so `false` beside a -real count is the ordinary case of reporting from a project whose switch never covered that -work, never a contradiction (see "Attributing records to a task" for the same scope split -elsewhere in this object). - -Every object carries `cost_report_version`, currently `15`. - -Bumped from `14` when `by_person`'s `resolution` gained a fourth value, -**`"this-machine"`**: the record carried no identifier of its own, and this machine has -declared an identity. It exists because `person_id` is written onto a record when the record -is *stored*, so whether one carries it depends on when the identity was declared relative to -when that record was read — never on the work. Measured on a live machine: 29,207 requests, -every one read by that machine's own `aidd`, and `by_person` could name none of them; the -same sink, the same identity file, answered differently depending on the order those two -things happened in. Declaring an identity now names the whole history rather than only what -follows. Sound because the sink has exactly one writer and every line it holds carries -`provenance: "local-read"` — a record in it was read by this machine's own reader. A consumer -that switched exhaustively on the three previous values must add this one; every row's -`totals` still reconciles to the period total. - -Bumped from `13` when the reasons a `by_task` or `by_backlog` row can carry gained a -fifth, **`"precedes-journal"`**: this record's moment is older than the earliest moment its -own session's journal witnessed. Until then those records read `"precedes-declaration"`, -which asserts that the work named its task late — and measured on 2026-09-04, 96.2% of a -real period (28,570 of 29,207 requests) read that way while fewer than one in five hundred -of them had actually declared late. The population is ordinary, not an anomaly: reading a -resumed transcript stores the turns it inherited under the session that read them, dated -when each was *billed*, days before that session opened a journal. Keyed on the journal's -own earliest witnessed moment, the same field the `"inferred"` bound above already reads, -never on its `session_start` line. Decided before `"no-declaration"`, so a journal that -declared nothing *and* did not cover the record is named by the coverage fact. A consumer -that switched exhaustively on the four previous reasons must add this one; every row's -`totals` still reconciles to the period total. - -Bumped from `12` when **`by_prompt`** joined the top-level breakdowns — the prompt that -caused the work, grouped by the turn each record came from. It is the one breakdown no host -limit can leave empty: every record the reader stores already carries its own turn, where -`by_step` depends on a host naming a skill and reads a few percent on a session whose real -cost sits in subagents. A row with no `prompt` is a record whose turn nothing named, placed -last like every other axis' remainder rather than dropped. A consumer that summed every -breakdown's `requests` against `totals.requests` has one more breakdown to sum. - -Bumped from `11` when **`by_task`'s `attribution` stopped being always `"declared"`**. A -record no declaration covers is now named after the one task folder its session wrote into, -marked `"inferred"`, so one task can hold two rows — one per route — the same -`(name × attribution)` shape `by_task`'s neighbour `by_step` has always had. A consumer that -read `attribution` as a constant, or `by_task` as one row per task, misreads this version; -summing every row's `totals` still reconciles to the period total. Two bounds make the route -sound and both are load-bearing: a session that wrote into **two** task folders infers -nothing, since there is no reason to choose between them; and a record outside the span its -own journal witnessed infers nothing either — measured, a session whose journal was lost and -recreated witnessed four minutes while the sink held its records from seven days back, and -without that bound all seven days would have been attributed to a folder it touched today. -Nothing new is captured for this: `file_written` was already written by the hook at turn end. - -Bumped from `10` when the reasons a `by_task` or `by_backlog` row can carry gained a -fourth, **`"no-journal"`**. It separates a fact about the read from a fact about the work: -until then, a record whose session had no usable run journal was given `"no-declaration"`, -which asserts that the session declared no task. Measured on -2026-09-04, running the report from a subdirectory of the repository put 100% of a period -into `"no usable task declaration in this session"` while every journal sat one directory -up, unread — the reader anchored at the process working directory and the hook that writes -a journal anchors at the repository root. The reader now anchors where the writer does, and -this reason covers what the anchor cannot reach: a project whose journals are on another -machine, a period read outside any checkout, a session whose journal was removed. A -consumer that switched exhaustively on the three previous reasons must add this one; every -row's `totals` still reconciles to the period total exactly as before. - -Bumped from `12` when **`by_prompt`** joined the top-level breakdowns. It is the only -breakdown no host limit can empty: every other one depends on a capture that may not have -happened — a run journal, an identity file, a task declaration, a host that names the skill -it is running — while this one depends on a field the transcript reader resolves for itself, -by walking `parentUuid` back to the turn that caused the work. That is not the same as -complete: measured 2026-09-05 on one machine's sink, 845 of 30,714 records carry no -`prompt_id`, and all but one of them were stored by a reader that predates the resolution — -see **`by_prompt`** below. Nothing new is captured for it. -A row carries `started_at`, the earliest moment in that prompt, because a prompt id alone is -opaque — it is what a person greps for in their own transcript. The row for records that -named no prompt carries neither `prompt` nor `started_at`, is placed last rather than ranked -among the prompts, and holds the whole period on every tool but Claude Code, which is the -truth for a route whose files cannot say which turn caused what. A consumer summing every -breakdown's `requests` against `totals.requests` has one more to include. - -Bumped from `9` when **`by_agent`** joined the top-level breakdowns. It exists because that -is where the spend is: measured on a live session, ten subagent transcripts held 432M of its -466M tokens, and every one of their lines names its agent (`attributionAgent`, 100% of -subagent tokens) where almost none names a skill (2.7%). That is why `by_step` can read a -few percent while a session's real cost sits elsewhere — the host names a skill on the main -thread alone, and no reader can invent one. `by_agent` needs no new capture: `agent_name` was -already on the record. Every row carries `attribution`, and it is what tells the two rows -that name no agent apart: `main-thread` is a tool that names agents saying this record -belongs to none of them — a session starts there, and it is never "no agent" — while -`not-stated` is a tool whose route never names one. Only Claude Code's route does today, so -on Codex, Copilot and OpenCode every record joins the `not-stated` row. It used to join the -main thread's, which asserted of those tools a fact nothing observed. - -Bumped from `8` to `9` when `attribution` gained a fourth value, `prompt-matched`. - -Bumped from `7` to `8` when `by_flow` -joined the top-level breakdowns (a consumer summing every breakdown's `requests` against -`totals.requests` now has a seventh breakdown to include). `by_flow` groups by which -orchestrated run the journal's own step sequence already names — no skill declares that it -orchestrates, so which ones do is a fact this object's own reader declares once, never -matched from a plugin name — and needs no new capture: the same `step_start` lines -`by_step` already reads are read again, between whichever of them the declared set names. -Bumped from `6` when `by_backlog` joined the top-level breakdowns (a consumer summing every -breakdown's `requests` against `totals.requests` now has a sixth breakdown to include). -`by_backlog` -regroups `by_task`'s own per-record membership one level up, by what each task's own -folder declares (`aidd_docs/tasks//backlog-link.json`) — never a second notion of -which task a record belongs to, and resolved once per task rather than once per record. -Bumped from `5` when the row -`by_task` gives for a record that fell in no declared interval stopped being a single row -and became up to three, one per `reason` actually present in the period -(`"no-declaration"`, `"precedes-declaration"`, `"journal-silent"`, joined at version `11` -by `"no-journal"` — see "Attributing records to a task" and the `by_task` section -below). A consumer that read "the one row with no `task`" as a single, whole-period fact -would misread this version; summing every -row's `totals` still reconciles to the period total exactly as before, only the count and -identity of rows with no `task` changes. Bumped from `4` when `by_task` -joined the top-level breakdowns (a consumer summing every breakdown's `requests` against -`totals.requests` now has a fifth breakdown to include). `by_task` groups by the same -closed, declared intervals the pre-existing `--task` filter already reads — never a -second notion of when a task was running — and is unrelated to `task_attribution`, which -still exists only alongside a `--task` filter. Bumped from `3` to `4` when `by_person` -joined the top-level breakdowns and `read` gained `identity_unusable` (a consumer -summing every breakdown's `requests` against `totals.requests` now has a fourth breakdown to -include). `identity_unusable` was reshaped from a boolean into a named cause, and the field -it replaced (`person_mapping_unusable`, over a separate mapping file) was deleted, before -version 4 ever shipped — no second bump announces either change, since nothing has read -this version yet. Bumped from `2` to `3` when `by_model` gained a row with no `model`, for a -record neither reader that permits one could name (a consumer that read `row.model` as -always a string on every prior version would misread this one). Bumped from `1` to `2` when -`by_day` and `by_project` joined `by_step`, `by_model` and `by_tool` as top-level breakdowns. - -**Set aside an object whose version you do not recognise rather than guessing its shape.** -The number is bumped when a consumer that understood the previous shape would misread this -one. Adding a field you may ignore is not a bump; changing what an existing field means is. - -## The shape - -```jsonc -{ - "cost_report_version": 15, - "period": { "from_day": "2026-07-01", "to_day": "2026-07-31" }, - "measurement_enabled": true, // this project's own switch, right now — see Versioning - "task": "2026_08/2026_08_21_cost-reporter", // absent unless --task was given - "filters": { "project": "acme/widgets" }, // absent unless a generic filter was given - "empty_selection": { "filter": "project", "value": "acme/ghost", "known": false }, // absent unless a filter, not the period, emptied this selection - "sessions": 1, - "totals": { "requests": 2, "input_tokens": 13930, "output_tokens": 4377, "cache_read_tokens": 165632, "cache_creation_tokens": 0 }, - "active_time_s": 2820, // absent when no record carried it - "by_step": [{ "step": "aidd-dev:02-implement", "attribution": "journal-interval", "totals": {} }], - "by_model": [{ "model": "gpt-5.6-sol", "totals": {} }], // a row with no "model" names none known - "by_agent": [{ "agent": "aidd-dev:executor", "attribution": "tool-stated", "totals": {} }, { "attribution": "main-thread", "totals": {} }, { "attribution": "not-stated", "totals": {} }], // "main-thread" is a tool that names agents saying this is none of them; "not-stated" is a tool whose route never names one - "by_prompt": [{ "prompt": "a-prompt-id", "started_at": "2026-07-01T09:00:00Z", "totals": {} }, { "totals": {} }], // one row per prompt, largest first; the last row, undated, is every record that named none - "by_tool": [{ "tool": "codex", "coverage": "covered", "reason": "…", "capability": {}, "totals": {}, "session_totals": {} }], // session_totals absent unless the tool has one (Copilot, today) - "by_project": [{ "project": "acme/widgets", "totals": {} }], // a row with no `project` names none known - "by_task": [{ "task": "2026_08/2026_08_21_cost-reporter", "attribution": "declared", "totals": {} }, { "reason": "precedes-declaration", "totals": {} }], // a row with no `task` carries `reason` instead, naming which of five facts applies; up to five such rows - "by_backlog": [{ "backlog": "ai-driven-dev/framework#661", "totals": {} }, { "declaration": "none", "totals": {} }, { "declaration": "unreadable", "totals": {} }, { "reason": "precedes-declaration", "totals": {} }], // a row with no `backlog` carries `declaration` (a known task naming none, or one whose declaration could not be read) or `reason` (a record in no task at all) — never both, never neither - "by_flow": [{ "flow": "aidd-orchestrator:01-sdlc", "attribution": "journal-interval", "started_at": "2026-07-01T09:00:00Z", "totals": {} }, { "flow": "aidd-orchestrator:01-sdlc", "attribution": "journal-interval", "started_at": "2026-07-01T11:00:00Z", "totals": {} }, { "flow": "aidd-orchestrator:01-sdlc", "attribution": "tool-stated", "totals": {} }, { "attribution": "unattributed", "totals": {} }], // two runs of the same skill are two rows, told apart by `started_at`; a `tool-stated` row is every run of that skill only the record's own tool named, so it carries no `started_at`; the last row is work that joined neither - "by_day": [{ "day": "2026-07-01", "totals": {} }], // every day in the period, in order, gaps included - "by_person": [{ "resolution": "mapped", "person": "a-person-id", "identities": ["a-person-id", "a-machine-id"], "totals": {} }], // mapped rows first, then every unplaced identity, then the one row for records carrying none - "attribution": [{ "attribution": "tool-stated", "totals": {} }], - "task_attribution": [{ "attribution": "declared", "totals": {} }], // present only alongside "task" - "read": { "undated_records": 0, "unreadable_lines": 0 } // identity_unusable absent: the identity was read fine -} -``` - -### Totals - -The same object appears as `totals` everywhere — at the top level and on every row. - -| Field | Meaning | -| --- | --- | -| `requests` | Billed requests. Always present. | -| `cost_micro_usd` | Whole micro-dollars. Divide by 1,000,000 for dollars, at the moment of display and not before. | -| `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | The four counters, disjoint — adding all four gives total tokens without counting anything twice. | - -**An absent counter means never observed, which is not zero.** A tool whose files carry no -amount has an *unknown* cost, not a free one. Print "unknown", never `$0.00`. - -**No amount reaches this object from a local read, on any tool, and no route can add a new -one going forward.** Claude Code's `cost_usd` only ever existed on its OTLP export, and -that route was removed — no code path in this repository can produce a new `"export"` -record. A stored one written before the removal can still be read (the sink is -append-only), so `cost_micro_usd` can still appear on this object for a period whose -history includes that data; it will never appear for work measured after the removal. If -you are reporting on locally-read sessions, you are reporting tokens; the rates that turn -them into money live outside this repository. - -### Breakdowns - -`by_step`, `by_model`, `by_tool`, `by_project`, `by_task`, `by_backlog` and `by_flow` are -ordered largest first, with a stable tie-break, so the biggest thing is the first thing you -read. Ordered by `cost_micro_usd` where a row has one, and by all four token counters -summed where it does not — never by `input_tokens` and `output_tokens` alone, which every -tool here dwarfs with cache. `by_task` places every row for what fell in no declared -interval last regardless of size, in `reason`'s own fixed order (`"no-journal"`, -`"precedes-journal"`, `"no-declaration"`, `"precedes-declaration"`, `"journal-silent"`) — the same convention -`by_person` gives its own no-identifier row, a reader sees tasks before the remainder, and -the remainder in the -same order every time. `by_backlog` places its own named rows first, then the row for a -known task declaring none, then the row for one whose declaration could not be read, then -every `reason` row in that same fixed order. `by_flow` carries no reason taxonomy the way -`by_task`'s and `by_backlog`'s own remainders do — a flow is read from the same sequence -either way, so there is only one fact to state about falling outside every one of them — -but its own single row for what fell in no flow interval is pinned last exactly like -theirs, never sorted by size with the named rows: that row is ordinarily the largest one -in a period, and sorting it in would lead the axis with its own remainder while every axis -beside it leads with its largest named row. `by_day` is the one exception: it is -chronological, one row per day the period spans — a series read out of order is not a -series, and a day nothing ran on is a row of zeros rather than an omitted day. - -**Every breakdown sums exactly back to `totals`.** That is asserted, on integers, not -hoped for. - -`by_step` is keyed by the step **and** the strength of its attribution: one skill reached -once from the tool's own statement and once from a journal interval is two rows, because -they are two different claims. A row with no `step` carries `attribution: "unattributed"`. - -`by_project` carries a row with no `project` for a record stored before this field existed, -or whose session journal named none — never folded into a project the reader happens to be -standing in. A record's project comes from the run journal that covered its session, not -from wherever the report itself happens to run. An empty string is treated the same as no -project at all - never its own row. - -`by_model` carries a row with no `model` the same way: both the Codex and OpenCode readers -permit a request with no model, and that record gets its own row rather than vanishing from -the breakdown while staying in `totals`. - -### `by_task` — grouped by the closed interval a record falls in, or a narrower inferred route of its own - -`by_task` groups first by exactly the same declared intervals `--task` already filters on -(see "Attributing records to a task"): a record's own moment either falls inside one -session's closed interval, or it does not. A record's session is closed, sequential -intervals never overlap (see "Attributing records to a task"), so at most one declared -interval ever matches. - -Since `cost_report_version` `12`, a record no declared interval covers can still land under -a task through a second, narrower route of its own — never the `--task` filter's own -"inferred" route wholesale, because that route decides for a whole session at once and, for -a session that wrote into two task folders, would place the same undeclared record under -both — the opposite of what a breakdown promises, which is a partition. `by_task`'s own -route only fires when the session wrote into exactly **one** task folder and the record's -own moment falls inside what that session's journal actually witnessed; a record from a -session that touched two task folders, or one outside the witnessed window, gets no -task and a `reason` row instead. Bounded this way, one task can hold two rows — one per -route — but a record is never placed under two different tasks. - -`attribution` is present on every row that carries a `task`, naming which route placed it -there — travelling with the row rather than assumed, so a consumer never has to know which -route a breakdown reads. A row for what fell in no declared interval and no inferred one -carries `reason` instead of `task` and `attribution`, naming which distinct fact applies — -never one label standing in for all of them, and never more than one row per reason. The -first names a fact about the read, the rest facts about the work: - -`attribution` says which route named the task, and is present only on a row that names one: - -| `attribution` | What it means | -| --- | --- | -| `"declared"` | A `task_declared` interval in that record's session covers the record's own moment. | -| `"inferred"` | No declared interval covers it, its session wrote into exactly one task folder, and its own journal witnessed the moment. Weaker on purpose: the journal never said this record was that task's, only that the session touched that task and nothing else. | - -| `reason` | What it means | -| --- | --- | -| `"no-journal"` | No usable run journal reached this record's session. Either none was read for it at all — the journals are on another machine, the period was read from outside any checkout, the session's own journal was removed — or one was read and could not be used, its `session_start` header torn, so nothing here knows which session its lines belong to. Never says the work declared nothing. | -| `"precedes-journal"` | This record's moment is older than the earliest moment its session's journal witnessed. Nothing declared late here; no journal existed yet. Reading a resumed transcript stores the turns it inherited under the session that read them, dated when each was billed — so this row is ordinarily the largest one in a period, and says nothing about how the work behaved. | -| `"no-declaration"` | This record's session never declared a task at all. | -| `"precedes-declaration"` | A task was declared, this record's moment falls inside what the journal witnessed, and some declared interval starts *after* it — a record before the session's very first declaration. Declared intervals run contiguously from each declaration to the next, so no gap exists between two of them. | -| `"journal-silent"` | A task was declared, every declared interval starts at or before this record's moment, and none of them reaches it — the journal's own declared coverage ran out before this record's moment did. | - -Up to five such rows can appear in one period — one per reason actually present, never -fewer, and never two different gaps collapsed into one row the way a single, undifferentiated -"no declared interval" row used to read before `cost_report_version` `6`. A session whose -declaration could not be read produces the same `"no-declaration"` row a session that never -declared one does — there is no signal in the journal that would tell those two apart. Where -that possibility matters, `read.unreadable_lines` already carries it, for these rows as for -the whole object. - -`by_task` sums to `totals.requests` exactly like every other breakdown. - -**Alongside a `--task` filter, a row carrying `reason` can still appear, and is not a -contradiction of the header naming that task.** `--task` keeps a session's records through -its own "inferred" route for a record no declared interval covers — whole-session and -unbounded by time, so a session that wrote into that task's folder at any point keeps every -one of its otherwise-undeclared records, whichever task is being filtered for. `by_task`'s -own inferred route is narrower — bounded to a session that wrote into exactly **one** task -folder and to a record inside what that session's journal actually witnessed — so a record -`--task` would keep can still land in a `reason` row here. Read the row as it is named: no -*declared interval, and no bounded inferred route,* covers this record — not "this session -never touched a task." Cross-check against `task_attribution`'s own `declared`/`inferred` -split when the broader, whole-session reading is what is wanted. - -### `by_backlog` — grouped by what each task's own folder declares - -`by_backlog` answers a different question than `by_task`: not "which task", but "which -backlog item" — the move from "this task cost X" to "issue #661 cost X". It regroups -`by_task`'s own per-record membership one level up: every record that `by_task` already -places under a named task is looked up once more, against that task's own -`aidd_docs/tasks//backlog-link.json`, and re-keyed on the item it declares. A record -`by_task` places in a `reason` row (no task at all) is untouched — it carries the identical -`reason` here, since there is no task to ask a question of in the first place. - -A row with no `backlog` carries exactly one of `declaration` or `reason`, never both and -never neither: - -| Field | Value | Means | -| --- | --- | --- | -| `declaration` | `"none"` | The record's task is known, but its folder declares no backlog item. A normal state, its own row. | -| `declaration` | `"unreadable"` | The record's task folder's declaration exists but could not be parsed. Its own row, costing that row's resolution and no figure — the record is still counted, here and everywhere else. | -| `reason` | one of `by_task`'s three | The record belongs to no task at all — see "Attributing records to a task". | - -**Two tasks declaring the same item merge into one row — that is the point of this axis.** -A story delivered across two task folders, each with its own `backlog-link.json` naming the -same reference or the same path, reads as one row here, with both tasks' records folded -into it. Two tasks that each declare nothing do **not** merge into each other's figures by -virtue of both declaring nothing — they both land in the single `declaration: "none"` row, -which is one row precisely because "declares nothing" is one state, not because the two -tasks were confused for each other. - -`by_backlog` sums to `totals.requests` exactly like every other breakdown — a damaged or -absent declaration changes how a task's records are grouped, never how many are counted. - -### `by_flow` — grouped by the orchestrated run the journal's own sequence names - -`by_flow` answers "what did this orchestrated run cost" — a level above a step and -unrelated to a task: `aidd-orchestrator:01-sdlc` running end to end is one flow, whatever -tasks or backlog items it touched along the way. Nothing new is captured for it. Skill -detection already writes a `step_start` line for any skill, orchestrating or not (the same -lines `by_step` reads), so a flow is *read* from that sequence, never declared by a hook: -one flow opens at an orchestrating skill's own `step_start` and closes at a `step_end` -naming that same skill, at the next orchestrating `step_start`, or — for a flow nothing ever -closes — at the journal's own last witnessed moment. A `turn_end` is a pause, not a close: a -flow spanning three prompts is exactly the case a `turn_end` used to cut short. - -**Which skills orchestrate is declared, once, never matched from a plugin string.** No -skill's own frontmatter says it orchestrates, and more than one skill in this framework's -`aidd-orchestrator` plugin plausibly does. `flow-attribution.ts`'s `ORCHESTRATING_SKILLS` -names them explicitly, in both spellings the journal can carry for the same skill (an -argument-stated name and a bare directory name — see that module's own doc comment for -why both exist) — extending it for a project's own orchestrator is the one change adding a -new one to this axis ever needs. - -A row's `flow` names the orchestrating skill, `attribution` says how that flow came to be -known, and `started_at` names the moment it opened. All three tell two rows apart: **two -orchestrated runs of the same skill in one session are two rows, never merged into one** — -an `attribution: "journal-interval"` row groups by the closed interval a record's own moment -falls inside, not by the skill's name alone, the same distinction `by_step` already draws -between a step reached by two different routes. A row with no `flow` at all, carrying -`attribution: "unattributed"`, is every record that joined neither an interval nor a stated -flow. There is no `reason` breakdown the way `by_task`'s remainder has one: a flow is read -from the same sequence whichever way a record misses it, so there is only ever the one fact -to state. - -**A flow a record's own tool named is a flow.** A session resumed after its context was -compacted invokes nothing again, so no `step_start` hook fires and its journal opens no flow -— while the transcript goes on stating the step on every record it produces. A record no -interval covers, whose own `step_attribution` is `tool-stated` and whose `step` names an -orchestrating skill, joins a row for that skill carrying `attribution: "tool-stated"`. That -row has no `started_at`: it is a bucket drawn from however many runs of that skill the tool -named, and a name is not a run. Only the tool's own statement opens such a row — a -`journal-interval` step is an inference from the very intervals already checked, and a -`prompt-matched` one names a step rather than an orchestration. - -**An interval wins over a stated flow, and the reason is granularity, not strength.** Where -a record falls inside an interval, that interval's row is the one it joins, even though its -tool stated the same skill. Elsewhere the preference runs the other way — `withStepBackfill` -prefers a tool-stated step over a journal-interval one — and the two are not in conflict, -because they answer different questions. There the question is *which skill*, and the tool -naming its own beats an inference from a moment. Here the question is *which run*, and only -an interval can say. - -**A skill a person runs by hand while a flow is open counts inside it.** The journal cannot -tell a hand-run skill from one the orchestrator itself invoked — both write the identical -`step_start` line — so neither can this breakdown. State the limit rather than guessing past -it. - -`by_flow` sums to `totals.requests` exactly like every other breakdown, and carries no -filter of its own — grouping only, the same as `by_person`. - -### `by_prompt` — the axis no host limit can empty - -Every other breakdown can read low for a reason that is about the capture, not the work: a -skill the host never named, a journal that was never written, an identity nobody declared. -This one groups on `prompt_id`, which the transcript reader resolves for every usage line it -stores — subagent lines included, by walking each file's own `parentUuid` chain back to the -turn that caused the work. - -| Row | Means | -| --- | --- | -| `prompt` and `started_at` | one prompt, and the earliest moment measured inside it | -| neither field | every record no prompt could be resolved for | - -**Read the second row as a real quantity, not a rounding error.** A sink accumulates across -reader versions, and a record's fields are fixed the first time its turn is stored: a record -written before this resolution shipped never gains a `prompt_id`, however often the sink is -read again. Measured 2026-09-05 on one machine's sink of 30,714 records: 845 carry none — -34 written before the CLI stamped a version, 810 by one session's reader before the -resolution shipped, and exactly one by the current reader, an assistant line whose -`parentUuid` chain reaches no line naming a prompt. Re-reading recovers little of it: 720 of -those requests name no line any transcript on disk still holds. - -Ordered largest first, like every breakdown but `by_day`; the remainder row is placed last -rather than ranked, because a bucket drawn from many turns has no size comparable to a single -turn's. The text rendering prints the ten largest and then says how many it withheld — one -row per turn is unbounded where every other axis has a small vocabulary — while this object -always carries them all. - -### `by_person` — three outcomes, never a merge - -`by_person` resolves each record's `person_id` against the machine's own identity file -(`~/.config/aidd/identity.json`, or the platform equivalent — see `aidd telemetry identity`), -never against a git author, an email or a hostname. That file describes exactly one person: -its own `person_id`, how it was obtained (`origin`: `"minted"` here or `"adopted"` from -another machine), and every identifier added onto it with `aidd telemetry identity link` -(`also_me`). Each row's `resolution` is one of four: - -| `resolution` | Means | -| --- | --- | -| `mapped` | The identifier is this machine's own person — its `person_id` or a member of `also_me`. `person` carries the canonical identifier, and `identities` carries every raw identifier behind the row, including that canonical one. | -| `unresolved` | The identifier is real, but the identity file does not cover it. `identities` carries that one raw identifier; `person` is absent. | -| `this-machine` | The record carried no identifier of its own, and this machine has declared an identity. `person` and `identities` carry that identity exactly as a `mapped` row does; the two are kept apart because they are different facts — `mapped` is the record naming a person this identity claims, this is the identity claiming a record that named nobody. | -| `none` | The record carried no identifier at all **and** no identity is declared — a different fact from `unresolved`: nobody opted in, rather than somebody did on a machine or tool this identity has not heard of. | - -**Two raw identifiers one person declared merge into one `mapped` row; two unplaced -identifiers never merge into each other.** The identity file describes exactly one person, -so there is no shape in which two people could claim one identifier in the first place — -unlike a lookup table, nothing here can be edited into that state. Rows are ordered by how strong the claim is: `mapped` -first, then the one `this-machine` row, then every unresolved identity, then the one `none` -row last; largest first within each group. - -`by_person` sums to `totals.requests` exactly like every other breakdown — a damaged or -undeclared identity changes how records are labelled, never how many are counted. - -### `session_totals` — a session total, never a sum of requests - -`by_tool` rows carry `totals`, summed from `kind: "request"` records, exactly like every -other breakdown in this object. A `by_tool` row can also carry `session_totals` — present -only for a tool whose own file yields one already-complete, per-session figure rather than -per-request records. Today that is Copilot alone: its `session.shutdown` event reports the -whole session's four token counters once, at the end, never per call. - -**The two are never the same number and are never added together.** `totals.requests` -counts billed requests; a tool that has none of those (Copilot) reports `requests: 0` there -regardless of what `session_totals` carries. Read `session_totals` as its own answer to "what -did this session report", not as a fallback for a zero in `totals`. It carries no -`cost_usd` — the tool's own file states no billed amount for it, only a session's tokens. - -`session_totals` is absent, never present-and-empty, for every tool that has none — reading -it as `{ "requests": 0 }` by default would claim a session total was measured and found -empty, which is a different fact from the tool never producing this figure at all. - -### Attribution - -`attribution` always has exactly four rows, in this order: - -| `attribution` | Means | -| --- | --- | -| `tool-stated` | The tool named the running skill itself, on the line with the counters. Exact. | -| `prompt-matched` | The record's own prompt opened a step, and both sides name the same one — an identifier two sources agree on, stronger than an inference from moments and the one reading that stays true when two tasks advance at once. | -| `journal-interval` | Derived from the interval between two boundaries the framework recorded. An inference. | -| `unattributed` | Neither source could say. | - -A strength that accounts for nothing is present with `requests: 0`. That zero is a -measurement — the total is known and none of it came from that source. - -**`unattributed` does not mean no step ran.** On at least one measured tool the two are -indistinguishable, so the stronger reading would be a fact nobody measured. Do not collapse -it into anything else, and do not call it a residual. - -### Task attribution - -`task_attribution` exists only alongside `task` — an unfiltered period carries no -per-record task identity to break down, so there is nothing here to say for it. Where -present it always has exactly two rows, in this order: - -| `attribution` | Means | -| --- | --- | -| `declared` | The record's own moment fell inside an interval a flow explicitly opened, by naming a file under this task's folder in a tool call — a run journal `task_declared` line. Works on every tool the journal hook reaches, not only the one whose payload names a written path. | -| `inferred` | The record's session wrote into the task folder at some point, with no declared interval covering this specific record. The pre-existing, whole-session route. | - -A source that accounts for nothing is present with `requests: 0`, the same convention -`attribution` uses. There is no `unattributed` row here: every record inside a `--task` -report already matched one of the two routes, or it would not be in the report at all. - -**A declaration is bounded, never boundless.** It closes at whichever of a later -declaration or a turn boundary comes next; left open by a session that never closed it -(a crash, most often), it is capped at the last moment that session's journal actually -recorded — never at "still open," which would let one long-running session's later, -unrelated work read as this task's cost. - -### Capability, per tool - -This is the field that makes the contract the same across tools. **Branch on it. Never -infer a tool's limits from whether a number happened to be present** — a tool that cannot -supply an amount and a session that cost nothing look identical in the numbers. - -```jsonc -"capability": { - "local_read": { "token_counters": true, "amount": false, "tool_stated_step": false, "agent_name": false }, - "export": null, - "journal_attributable": true, - "task_attributable": false -} -``` - -| Field | Meaning | -| --- | --- | -| `local_read` | What that route was **measured** to supply. `null` means the tool declares no such route at all, which is not the same as a declared route supplying nothing. | -| `export` | **Always `null`, on every tool.** The export route was removed; no tool declares it any more, so there is nothing left to measure a shape for. Kept as a field, rather than dropped from the object, because dropping it would be a `--json` shape change for a capability that used to vary by tool and now simply never does. | -| `token_counters` | That route yields the four counters. | -| `amount` | That route yields a figure denominated in currency. Never a credit or a premium request. | -| `tool_stated_step` | The tool names the running step itself. A journal interval is not this. | -| `agent_name` | The route names the agent a record belongs to, and so also says when one is the main thread's own. Without it, `by_agent` reads this tool's records as `not-stated`, never as the main thread. | -| `journal_attributable` | The run journal names this tool's sessions. **False means two things:** no step can come from an interval, *and* a read that sweeps the journal never reaches one of its sessions — so the tool can be perfectly readable and still report nothing until someone names a session by hand. | -| `task_attributable` | A session on this tool can be traced to the task it worked on — declared, inferred, or both. False only where the journal hook never reaches a tool call for this host at all, since a declaration needs a tool call's own arguments to read; true for every declared host today, OpenCode included as of 2026-08-31 (`registry-conformance.unit.test.ts` keeps this tied to the journal hook's own dispatch rather than typed in twice by hand). | - -`coverage` is `"covered"` or `"not-covered"`, and `reason` says why when it is the second, -or what a covered tool's figures cannot be used for. - -**Five silences, and only one is a zero.** A tool with `requests: 0` may be: not covered at -all (`coverage: "not-covered"`, read `reason`), covered but unreachable by the sweep -(`journal_attributable: false`), covered and reached and idle (a real zero), covered and -its reader failed (the human output says so; `aidd telemetry read` reports it per tool), or -covered and reporting only a `session_totals` figure — `requests: 0` there is correct and -permanent for that tool, not a silence to explain away. - -### What the read could not do - -```jsonc -"read": { "undated_records": 3, "unreadable_lines": 2, "identity_unusable": "unreadable" } -``` - -`undated_records` are records carrying no moment at all. They belong to **no** period — -the only other moment available is the day the line was stored, which is when AIDD heard -about the work rather than when it happened. `unreadable_lines` are lines no parser could -read. - -**`undated_records` or `unreadable_lines` present or non-zero means your total is -partial.** Say so rather than presenting it as whole. `identity_unusable` is a different -kind of field and does not make a total partial: it names which of two causes kept this -machine's own identity from resolving records at all, `"unreadable"` for a declared -identity file that could not be read back, `"absent"` for no identity declared at all -(the ordinary state of anyone who has never opted in - not a degraded read). Either way, -exactly as `by_person`'s own section states, a damaged or undeclared identity changes how -records are labelled, never how many are counted: every record is still counted, in -`by_person` as `unresolved`, never as a reason to drop a figure. The field itself is -absent from `read` only when the identity was read back fine - `by_person`'s own rows are -what shows a resolved identity's effect. - -## Filling it - -Records reach storage when someone runs: - -```bash -aidd telemetry read # every session the run journal knows -aidd telemetry read --session -``` - -A period that reports nothing usually means its sessions have not been read yet. - -## Known limits - -[The plugin README](../../plugins/aidd-telemetry/README.md) states what each tool can and -cannot be measured for, and why. Read it before explaining a missing figure. diff --git a/aidd_docs/product/metrics-contract.md b/aidd_docs/product/metrics-contract.md deleted file mode 100644 index fd16627c8..000000000 --- a/aidd_docs/product/metrics-contract.md +++ /dev/null @@ -1,925 +0,0 @@ -# Metrics contract - -This is the contract for `TelemetrySinkRecord`, the one shape every AI-tool telemetry -line takes once it reaches storage. It is written for a consumer outside this -repository — a pricing service, an aggregator — that needs to price and attribute a -session's usage without reading this repository's source. - -> **Writing a skill, or anything that reports on AIDD work?** Read -> [`cost-report-contract.md`](./cost-report-contract.md) instead. It describes the object -> `aidd telemetry report --json` prints, with the rules below already applied. Reading raw -> records makes you responsible for the three double-count rules, the split between the two -> record kinds, and re-read deduplication — which is worth doing once, in one place, and -> that place already exists. Everything a correct -consumer needs is below: the file layout, every field's meaning and presence -condition, the three ways a naive reader double counts, and what each tool can and -cannot supply. - -> **The export route no longer writes.** The OTLP receiver, the export -> configuration and the mapper that turned a captured payload into a -> `provenance: "export"` record were all deleted in "one route, and every -> sentence about it true" — no code path in this repository can produce a new -> `"export"` line today. Everything below that describes the export route is -> kept, unchanged, because `provenance: "export"` stays a valid value on a -> stored line: the sink is append-only, a line already written under an -> earlier version of this tool is never rewritten, and a consumer reading a -> day file that predates this change still needs every rule below to read -> that line correctly. Read every present-tense sentence about the export -> route as a description of a stored line's own shape, not of a route still -> capable of adding to it. - -## Where records live - -Records are appended as JSON Lines, one JSON object per line, to a UTC-day file: - -``` -~/.config/aidd/telemetry/YYYY-MM-DD.jsonl -``` - -or under `$AIDD_TELEMETRY_DIR/YYYY-MM-DD.jsonl` when that environment variable is set — -it names this directory outright. `$AIDD_USER_CONFIG_DIR/telemetry/YYYY-MM-DD.jsonl` is -still honoured, for a machine configured before the two were split, and names the -directory *above* this one; a consumer has to look in both. The split exists because -`AIDD_USER_CONFIG_DIR` also relocates `auth.json`, so it could never be the variable a -team shares. A day file is append-only for its whole life — lines are never -rewritten in place, only added. A session's records can span more than one day -file if the session crosses midnight. - -Every record carries `sink_schema_version` (currently `2`). A consumer that does -not recognize the version on a line should set that line aside rather than guess -its shape — a version exists precisely so a future, incompatible shape does not -get read as this one. - -## The two record kinds, and why they are never summed - -Every record's `kind` is either `"request"` or `"session"`, and the two measure -overlapping quantities in incompatible ways. - -**`kind: "request"`** is one line per billed request — one line per call to the -model that produced a charge. `cost_usd` and the four token counters -(`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`) on -a `"request"` line are complete for that request: summing every `"request"` line -for a session gives that session's true total. - -**`kind: "session"`** is a periodic delta of the same quantities, taken from a -metrics export that flushes on a fixed interval (10 seconds, for Claude Code — -`OTEL_METRIC_EXPORT_INTERVAL`) with delta aggregation temporality -(`aggregationTemporality: 1` in the OTLP payload): each flush reports only what -changed *since the previous flush*, not a running total. A `"session"` line is -**not** a per-session cumulative figure, and it is not guaranteed complete — -whichever flush windows happened to be exported before the process exited are -what got captured, and no more. Summing `"session"` lines therefore does not -reliably reproduce a session's true total, even before double-counting against -`"request"` lines is considered. - -Copilot's is the exception that shows why the kind is drawn where it is: read -locally rather than exported, it is a **one-shot cumulative total** written once -at shutdown rather than a delta of a flush window. Both meanings share the rule -that matters — a `"session"` line is never added to a `"request"` line, because -one already contains what the other counts — so they share the kind. What -separates them is `provenance`: `"export"` for a flush delta, `"local-read"` for -a total a tool wrote for itself. A consumer that needs to tell them apart reads -that field, and no other. - -**Measured on one captured session** (Claude Code, `session.id` = -`22177147-d8cb-4ee1-976f-0ef82bd62491`, captured 2026-08-20). The export route these -figures came from, and the two OTLP fixtures below, were deleted in "one route, and every -sentence about it true" — the figures are kept as historical measurement, on a capture no -longer in this repository: - -| Source | Kind | Lines | `cost_usd` total | -| ----------------------------------------------- | ----------- | ----- | ----------------- | -| `otlp-logs-claude-code-subagent.json` fixture (deleted) | `"request"` | 2 | **$0.1605** | -| `otlp-metrics-claude-code.json` fixture (deleted) | `"session"` | 1 (of 6) | **$0.0151** | - -This is not a contradiction: the request lines are every billed request the -session made; the metric line is one 10-second flush window's own delta. Summing -the two ($0.1605 + $0.0151 = $0.1756) overstates the session's true cost, and -using only the metric total ($0.0151) understates it by an order of magnitude, -because only one flush window was ever captured for this session. - -**Rule: take `cost_usd` and the four token counters from `kind: "request"` lines -only.** Take `active_time_s` from `kind: "session"` lines only — no `"request"` -line, on any tool measured so far, carries active time; it exists solely as a -`"session"`-kind metric. - -### One line per datapoint, never merged - -A `kind: "session"` line is one metric datapoint, never merged with any other -datapoint from the same flush. The captured session above produced **six** -`"session"` lines for one flush window, one per datapoint: `cost_usd`, -`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, and -`active_time_s` — each its own line, each carrying only that one field among the -six (the other five are absent on that line). A consumer expecting one -`"session"` line per session, or one line per flush bundling every quantity -together, reads a fifth (or a sixth) of the truth per line it looks at. - -## The other way to double count: a re-read appends unless matched - -Local reading (`provenance: "local-read"`) works by re-opening a tool's own -transcript file, which keeps growing for as long as the session runs. Each read -sees every turn the file holds so far, not just what is new since the last read. -To keep a re-read from storing the same turn twice, the writer matches each -candidate record against what is already stored for that session, on `turn_id` -alone (never on line content, never on arrival order — a hash of the line changes -the moment the tool appends anything else to that same record). - -**This match requires a `turn_id`.** A candidate with no `turn_id` cannot be -matched against anything, and is appended again on every read that sees it — by -design, not by omission: inventing an unstable key would be worse than leaving it -unmatched. - -**A matched candidate is not always dropped — a still-open turn can correct -itself.** Some tools (Codex's rollout is the one measured so far) emit a -`kind: "request"` record for a turn before the tool itself has said the turn is -finished: a Codex turn is closed by the *next* `turn_context` line, and the last -turn in a file whose session is still running has none, so a rollout read while -its session runs is stored with whatever counters had arrived by then — partial, -and, without this rule, permanently so, since the next read's completed figures -would match the same `turn_id` and be silently dropped. Instead: a matched -candidate lands as a second line — the sink is append-only, so this is never an -edit of the stored one — whenever it is a `kind: "request"` local-read record -that **strictly improves** on the largest already stored for that `turn_id` -(every counter at least as large, at least one larger). Nothing else gates it: -a run journal's `turn_end` line, where one exists, says only that no further -growth is coming — it is never asked before accepting a correction, because a -candidate that strictly improves is itself the only proof needed that an -earlier reading was not final, whatever a journal's own clock says about it. -Idempotence — a re-read that brings nothing larger stores nothing new — falls -out of "strictly improves" alone, the same property an unmatched-then-matched -`turn_id` already had; nothing here depends on a session ever being confirmed -finished. A `kind: "session"` record sharing a `turn_id` (Copilot's shutdown -total, keyed on the shutdown event's own id) is never treated as a correction -opportunity: it is a one-shot cumulative figure with no provisional reading to -correct, and is dropped on a re-read exactly as before. - -Because more than one record can now legitimately share a `turn_id` on the -sink, a consumer reading raw records must also collapse them before summing — -see "Consuming a session correctly" below. The largest wins, never a sum of two: -a later record that reads *smaller* than one already stored is never written at -all, so a stored `turn_id` group never needs to guard against a shrink, only -against counting more than one of its members. - -**Worked example**, mirroring the tested behavior of the local-read use case: one -turn's transcript line carries 10 input tokens and 20 output tokens, under -`turn_id: "req_1"`. A first read appends it — 30 tokens stored. The session -continues and the same transcript file is read again (a re-read, same turn still -present in the file, no larger reading of it yet). Because `req_1` is already -stored and the new reading offers nothing more, the second read matches it and -stores nothing new. **Stored total after the second read: 30 tokens, not 60.** -Had the same candidate carried no `turn_id`, the second read would have appended -it again, and the stored total would have become 60 tokens for one real turn. -Had the second read's own transcript line instead carried 15 input tokens and 25 -output tokens for the same still-open turn, it would land as a second, -correcting line, and the group's true total would be 40 tokens — the larger -reading, not 30, not 70. - -A consumer aggregating raw appends from the sink file without replicating this -`turn_id` match — for example, re-implementing a local reader against a tool's -own files rather than consuming this sink — will double, triple, or *N*-times -count any record whose route has no stable per-record identifier, once per read -of an active session; and, for a route whose turns can be re-read while still -open, will sum a turn's own successive readings of itself rather than keeping -only the largest. - -## A third way to double count: one billed call, seen by both routes - -A tool whose export and local read were both declared and measured — only -Claude Code, ever — could have both routes live for the same session at -once: the OTLP export streamed `api_request` as each call completed, and a -local read of the same session's transcript, run at any point, sees the same -calls in the tool's own file. Each route names the call in a namespace the other never -reads — export's `turn_id` is `prompt.id`, one user turn, which a main-agent -request and the subagent it spawned can share (see `turn_id` below); local's -`turn_id` is `requestId`, one billed call — so matching on `turn_id` alone, as -the re-read rule above does, never catches this: the two routes' records for -the same real call never share that key, and a consumer that only guards -against the re-read case sums both. **Measured on a captured export fixture no longer in -this repository** (`otlp-logs-claude-code-subagent.json`, deleted with the export route it -described, a main-agent request and the subagent request it spawned): naively unioning -those two export records with what a local read of the same two calls would -produce doubles every figure — four `"request"` lines instead of two, every -token counter twice its true value. - -**The fix is not a write-time match.** The sink is append-only (see "Where -records live"): a record already stored can never be corrected in place, only -reconciled by whatever reads it back. `billed_request_id` is the field that -makes the reconciliation possible — the one identifier measured so far that -both routes compute the same value for, for the same real call (see -`billed_request_id` below). A consumer building a session's true totals groups -`"request"` records sharing `tool`, `vendor_id` and `billed_request_id` and -treats them as one billed call: keep the group's `cost_usd` and four token -counters from whichever record carries `cost_usd` at all (on every tool -measured so far, that record's counters are also the complete ones for the -call), and take `step_attribution`/`step`/`step_plugin` from whichever record -resolved one, so a call seen by both routes still shows the export's money and -the local read's tool-stated step, never one thrown away for the other. A -record with no `billed_request_id` joins no group and is counted exactly as it -arrived — the same rule an unmatched `turn_id` follows for the re-read case. -`cost-report-contract.md` calls this the third of the double-count rules -`aidd telemetry report --json` has already applied. - -**The same collapse also absorbed a retried OTLP delivery, while the receiver -still ran.** `/v1/logs` and `/v1/metrics` were received unconditionally — -nothing at write time recognized a redelivered payload, because OTLP delivery -is itself at-least-once and a receiver refusing a delivery it cannot prove is -a duplicate would risk refusing a real one. A redelivered `api_request` named -the same `billed_request_id` as the first delivery, so the group it joined on -read had two (or more) identical records instead of one, and the same "keep -one, never sum" rule that reconciles two routes reconciled two deliveries of -one route just as well. A day file written while the receiver still ran can -still hold this shape; a consumer reading one applies the same collapse. - -**This protection required `billed_request_id`, and most exports never carried -one.** Measured while the route still existed, only Claude Code's export named -it (its `request_id` log attribute). Codex's and Copilot's exports were once -declared — `identityAttribute` was real, measured from a captured session — -but neither was ever measured carrying a `request_id`, or any counter at all -through this route; Cursor's and OpenCode's exports were never even declared -(`kind: "unmeasured"`), so the receiver never resolved an identity for either -and stored nothing from them regardless. A route with no `billed_request_id` -has no group to collapse into: a retried delivery for it is indistinguishable -from two real calls, and doubles every counter it carries. That was always a -latent gap, not an observed one, on any stored line — the one route ever seen -carrying real counters (Claude Code's) was also the one route that was -protected. A consumer reading a stored `"export"` line today reads it against -exactly the coverage measured while the route ran; no export will ever carry -`billed_request_id` beyond what is already on disk. - -## Identity and joins - -- **`tool`** names which AI tool produced a record, as a fact stated on the - record itself. A consumer never infers the tool from the name of another - field (`vendor_field`, `vendor_id`) — that attribute name differs by tool - *and by route*: the same Claude Code session identifier is named `sessionId` - when read locally and `session.id` when exported. Reversing the attribute name - back into a tool identity works only until a tool reuses another's attribute - name. -- **`vendor_id`** is that tool's own session identifier, as a string, in - whatever form the tool itself uses it — a UUID for Claude Code and Codex, an - OpenCode `ses_…` id, and so on. **`vendor_field`** names which attribute - carried it (`sessionId`, `session.id`, `session_meta.id`, `sessionID`, - `conversation.id`, `gen_ai.conversation.id`, depending on tool and route). Two - records with the same `tool` and the same `vendor_id` describe the same real - session, regardless of which route produced either one, since the identifier - value itself is the tool's own and does not change between its local file and - its export. -- **`project_id`** is the repository a session ran in, when it is known. - On the export route it is set directly from the `aidd.project_id` resource - attribute, with no join and no `project_field`. On the local-read route it - is joined from the run journal's own `session_start` line, which already - resolves `project_id` and `project_remote` for the repository the hook - fired in — the same value never re-derived from wherever the reader - happens to be standing. **`project_field`** names which of the journal's - two fields the value came from, present only on a record joined this way: - `"project_remote"` when the journal named a git remote (the same value for - every checkout of one repository), `"project_id"` otherwise (a directory - name, which can collide across machines). A record with neither field - belongs to no known project — never guessed at from the current - repository, and never dropped. -- **`turn_id`** is the tool's own identifier for one turn or request, when the - tool's file or export can name one. It is the key local-read re-reads are - matched on (above), but **it is not guaranteed unique to one billed request**: - measured on the captured session above, a main-agent request and the subagent - request it spawned share one `prompt.id` — two `"request"` lines, $0.1086 and - $0.0519, both under the same `turn_id`. Do not use `turn_id` as a primary key - for billed requests; use it only for the re-read match it exists for. - **`turn_field`** names which attribute carried it. -- **`person_id`** is not a tool's own, uncontrolled user attribute. This sink - once stored that as `user_id`, mapped straight from whatever `user.id` an - export happened to carry, regardless of whether the person using the tool - agreed to be named; that field is gone, and an export-provenance record now - carries no identity of any kind. `person_id` is the opposite in every way - that mattered: an identifier a person generated for themselves, on their own - machine, opted into per person rather than defaulted on for a whole export. - It is present only on a `provenance: "local-read"` record — the one route - guaranteed to run as that person, on that machine — and never on an - `"export"` record: no receiver exists any more to run as that person at - all, and even while one did, it was never guaranteed to run on the - identified person's own machine. `person_id` is never derived from `user_id`, - a git author, an email, or a hostname, in either direction. See `person_id` - and `person_display_name` below for what each carries and when. - -## Joining a commit to the session that made it - -Not a field of a record — it lives in git, and is the one link between what was -measured and what was produced. - -`aidd telemetry on` installs a `prepare-commit-msg` hook. A commit written while -an AI session was running carries a trailer: - -```text -AIDD-Session-Id: 33333333-3333-4333-8333-333333333333 -``` - -The value is the running session's own identifier, resolved from the environment. -It is not a second identifier minted for this purpose, so a commit joins to its -records on an equality with no resolution step in between. That equality is -measured on both hosts that write a trailer — see "How far the join is measured" -below, which also names the one case still open. - -Rules a consumer can rely on: - -- **A commit no AI session made carries no trailer.** The hook reads the running - session from the environment and writes nothing when it finds none. An absent - trailer means "not attributable", never "attributable to nobody". -- **A merge or a squash carries none either**, whatever was running. Those commits - bring in work that other commits already account for. -- **At most one per commit**, amend and re-run included. -- **What a commit already carries is never rewritten.** `aidd telemetry off` - removes the hook, so commits made after it carry nothing while the history keeps - what it has. Note it is that command which removes it, not the switch's value: - a project whose `.aidd/config.json` is edited by hand to `enabled: false` still - has the hook installed, and still trailers. -- **The trailer is not proof the session's records exist.** Turning the switch on - installs the hook, and a session whose records were never read, or were removed - with `aidd telemetry forget`, still leaves its trailer behind. A join that finds - no records on the other side is an ordinary outcome. - -### How far the join is measured, per host - -**Claude Code — measured.** `CLAUDE_CODE_SESSION_ID` is the transcript filename, -and a session is resolved locally by `.jsonl`, so the trailer's value -and the record's `vendor_id` are the same string. - -**Codex — measured, on two real sessions.** A fresh session and a resumed one -were run on 2026-09-02. Both reported the same `CODEX_THREAD_ID`, and the resume -wrote **no second rollout** — it appended to the first. So the variable tracks the -rollout, and the rollout's uuid is exactly the `vendor_id` records carry: the -trailer's value equals theirs. An earlier version of this section warned that a -thread spans several rollouts; the resume disproved it. - -One case is still open, and it is both narrow and bounded: 89 of 418 rollouts on -that machine are `thread_source: "subagent"`, where the rollout's own id and its -parent session's differ. Which of the two a subagent's `CODEX_THREAD_ID` carries -has not been captured. - -What that can cost is worth stating, because it is less than it sounds: those two -identifiers are the subagent and the thread that delegated to it, so the trailer -names one or the other and both are the same piece of work. The worst reading is a -commit attributed to the parent thread rather than the delegated turn inside it — -coarser than intended, never another person's session and never a different tree. -Where the named rollout has no records read, the outcome is the ordinary -"a join that finds no records on the other side" named above. - -Every ordinary session, fresh or resumed, joins exactly. - -**Cursor, Copilot, OpenCode — no trailer.** Nothing was measured that names their -running session in the environment, so the hook finds nothing and writes nothing. -That is the same rule as everywhere else here: an unknown is never a guess. - -The link the trailer closes is `execution → commit`. From there, `commit → pull -request → ticket` is the forge's own, and belongs to whoever reads it — this -framework neither writes nor resolves those. - -## Step attribution - -Every record states **how**, not just whether, its step is known, via -`step_attribution`: `"tool-stated"` (the tool itself reported the running -step, exact for that record), `"journal-interval"` (derived: the record's own -moment fell inside a step's start/end interval recorded by AIDD's run journal — -an inference, not a measurement), or `"unattributed"` (no step could be -determined by either route). `step_attribution` is always present; it is never -omitted, because an absent field here would read as "no step ran," which is -exactly the assertion nothing on a transcript or a journal can support. - -`step` (the skill or step name) is present exactly when `step_attribution` names -a source that found one — absent, never a placeholder, when `step_attribution` -is `"unattributed"`. `step_plugin` (the plugin the step came bundled with) is -present only when `step_attribution` is `"tool-stated"` and the tool reported a -plugin alongside the step name; a journal interval never carries a plugin at -all, so `step_plugin` is absent whenever `step_attribution` is -`"journal-interval"`, even though `step` itself is present there. - -**A journal interval ends where the journal says, and only a skill can say it.** The -interval runs from the `step_start` a skill's invocation wrote to the first of: a `step_end` -line naming that same skill, another `step_start`, or a `turn_end`. Only the first of those -is the end itself; the other two stand in for it. That matters because a `turn_end` is a -**pause** — a skill working across three prompts is credited with its first turn and nothing -after — and nothing any host emits says when a skill's work finished. Measured: a `Skill` -tool call's own `tool_result` returns about a tenth of a second after the call, which is the -dispatch, not the completion. So a skill declares its own end through a tool call it makes, -carrying `aidd:step-end ` in that call's arguments, and the hook writes the line. An -end naming a skill the session never started closes nothing, and an end never closes a step -other than the one it names — a skill invoking another must not end it. - -A step whose interval was closed by a stated `step_end` still reads `"journal-interval"`, not -a stronger value: the record was still placed by its moment falling inside a span, which is -the inference that value names. What the end changes is the span, never how the record met -it. - -**`step_attribution: "unattributed"` does not mean "this request ran outside any -step."** Claude Code's own attribution field is omitted from its transcript both -when no skill was running and when the running Claude Code version predates the -field (it arrived around version 2.1.220). Measured across 40 real transcripts -and twelve versions, there is not one `null` value that distinguishes the two -cases — the field is omitted identically either way. A consumer that reads -`"unattributed"` as "confirmed to be outside any step" is asserting a fact the -data cannot support. Read it only as: no step could be determined for this -record, for whatever reason. - -## Field reference - -Every field below states its type, when it is present, what it means, and — -because an absent counter and a zero counter are different facts — what its -absence means. - -### Always present - -#### `sink_schema_version` -- **Type**: number. -- **Present**: always. -- **Meaning**: the wire format version this line was written under. Currently `2`. -- **If absent**: never absent on a well-formed line; a line missing it, or - carrying a version a consumer does not recognize, should be set aside rather - than parsed as if its shape were known. - -Adding `person_id` and `person_display_name` was not a version bump: a -consumer built against version 2 that never wrote or read either field never -sees them, and neither changes what any field it already understood means — -the same rule `cost_report_version` follows in `cost-report-contract.md`. - -Removing a field is neither of the two cases above — it neither adds -something ignorable nor changes what a kept field means. Whether it needs a -bump turns on whether a version has shipped: once a consumer could be reading -a real line, taking the field away without warning breaks it silently. Before -that, the risk does not exist — nobody can depend on a field no released -build ever produced. Removing `user_id` from the allowlist is not a version -bump for exactly that reason: `sink_schema_version` 2 has never been -released, so no consumer anywhere has read a real line carrying it. A day -file a pre-release build already wrote may still carry `user_id` on an old -line — the sink is append-only (see "Where records live") — and a reader -ignores it exactly as it would any other field it does not recognize. - -#### `kind` -- **Type**: `"request"` or `"session"`. -- **Present**: always. -- **Meaning**: which of the two measurement kinds this line is — see "The two - record kinds" above. -- **If absent**: never absent. - -#### `provenance` -- **Type**: `"export"` or `"local-read"`. -- **Present**: always. -- **Meaning**: which route produced this line — a tool's OTLP export received - over `/v1/logs` or `/v1/metrics`, or a tool's own file read directly from disk. - Never defaulted, so a third route arriving later cannot be mistaken for one of - these two. -- **If absent**: never absent. - -#### `tool` -- **Type**: one of `"claude"`, `"cursor"`, `"copilot"`, `"opencode"`, `"codex"`. -- **Present**: always. -- **Meaning**: the AI tool that produced this record, stated directly — see - "Identity and joins" for why this is never inferred from another field. -- **If absent**: never absent. - -#### `vendor_id` -- **Type**: string. -- **Present**: always. -- **Meaning**: the tool's own session identifier — see "Identity and joins." -- **If absent**: never absent. - -#### `vendor_field` -- **Type**: string. -- **Present**: always. -- **Meaning**: which attribute on the source payload carried `vendor_id` — the - route as much as the tool (the same tool's own identifier can be named - differently on its local file versus its export). -- **If absent**: never absent. - -#### `step_attribution` -- **Type**: `"tool-stated"`, `"journal-interval"`, or `"unattributed"`. -- **Present**: always. -- **Meaning**: how the step (if any) was determined — see "Step attribution." -- **If absent**: never absent, deliberately — see "Step attribution" for why an - absent field here would be misread. - -### Identity and joins (conditional) - -#### `turn_id` -- **Type**: string. -- **Present**: conditional — when the producing route can name a stable - identifier for this specific turn or request. Present on most `"request"` - lines measured so far (Claude Code, Codex, OpenCode). Never present on any - export-route metric datapoint (no `"session"`-kind OTLP datapoint measured so - far carries a turn identifier), but present on Copilot's local-read - `"session"` line — its one-shot shutdown total is keyed on the shutdown - event's own id, so a re-read can match it the same way a `"request"` line's - is matched. -- **Meaning**: the tool's own turn/request identifier, and the key a local - re-read is matched on. **Not guaranteed unique per billed request** — a - main-agent request and the subagent request it spawns can share one `turn_id` - on the export route. A `kind: "request"`, `provenance: "local-read"` turn is - the one shape that can also be *corrected*, not just matched — see "A re-read - appends unless matched" above; a `"session"`-kind turn (Copilot's) and an - export-route turn are never corrected this way, only matched-and-dropped or - left unmatched. -- **If absent**: this record's route has no stable per-record identifier to - offer. It cannot be matched by a re-read, and will be appended again, once per - read, for as long as the session's underlying file keeps being re-read — see - "A re-read appends unless matched." - -#### `turn_field` -- **Type**: string. -- **Present**: conditional — present exactly when `turn_id` is present. -- **Meaning**: which attribute on the source payload carried `turn_id` - (`requestId`, `prompt.id`, `turn_id`, `id`, depending on tool and route). -- **If absent**: `turn_id` is also absent on this record. - -#### `billed_request_id` -- **Type**: string. -- **Present**: conditional — measured so far only for Claude Code, on both routes: - its export names it via the `request_id` attribute on the `api_request` log - record, and its local transcript names it via `requestId` — the same - attribute the local route already uses for `turn_id`. No other tool or route - has ever been measured naming a billed call this way. -- **Meaning**: the tool's own identifier for one billed call, and, unlike - `turn_id`, **guaranteed unique per billed request where it is present at - all** — a main-agent request and the subagent request it spawns each carry - their own. It exists so a consumer can collapse two records describing one - real call — made when both an export and a local read are live for the same - tool at once — into one, instead of summing both. See "One billed call, - both routes" below. Never used for the local-read re-read match `turn_id` - exists for, and never a primary key for anything beyond that collapse. -- **If absent**: this record's route has no stable per-call identifier to - offer beyond `turn_id`, or the record's tool has never been measured - carrying one. A record with no `billed_request_id` is never collapsed with - any other — it is kept exactly as it arrived, the same rule an unmatched - `turn_id` already follows for a local re-read. - -#### `step` -- **Type**: string. -- **Present**: conditional — present exactly when `step_attribution` is - `"tool-stated"` or `"journal-interval"`. -- **Meaning**: the skill or step name that was running. -- **If absent**: `step_attribution` is `"unattributed"` — no step name is known, - which is a different fact from "no step was running." Never a placeholder - string. - -#### `step_plugin` -- **Type**: string. -- **Present**: conditional — present only when `step_attribution` is - `"tool-stated"` *and* the tool reported a plugin name alongside the step. -- **Meaning**: the plugin the stated step came bundled with. -- **If absent**: either no step is known, the step came from a journal interval - (which never carries a plugin), or the tool named a step with no plugin. - -#### `project_id` -- **Type**: string. -- **Present**: conditional. On the export route: present when the emitting - environment set a project identity (the `aidd.project_id` resource - attribute). On the local-read route: present when the run journal's - `session_start` line named a project for the session — see "Identity and - joins." -- **Meaning**: the repository this session ran in. -- **If absent**: no project identity is known for this record — read as - belonging to no known project, never attributed to a guess. - -#### `project_field` -- **Type**: `"project_id"` or `"project_remote"`. -- **Present**: conditional — present only on a local-read record whose - `project_id` was joined from the run journal; absent on an export-route - record, whose `project_id` is set directly with no journal join to name a - source for. -- **Meaning**: which of the journal's own two fields `project_id` came - from — see "Identity and joins." -- **If absent**: either the record carries no `project_id` at all, or it - does and came from the export route. - -#### `person_id` -- **Type**: string. -- **Present**: conditional — present only on a `provenance: "local-read"` - record, and only when the machine that read it holds a file recording that a - person opted in. Never present on a `provenance: "export"` record. -- **Meaning**: a stable identifier a person generated for themselves and chose - to attach, on their own machine — never derived from the export route's - now-removed `user_id`, a git author, an email, or a hostname. Withdrawing - removes the file this comes from; records already written keep whatever - they were stamped with, since a day file is never rewritten in place (see - "Where records live"). -- **If absent**: nobody opted in on the machine that produced this record — - the default for a fresh installation — or the record came from the export - route, which never carries this field regardless of any opt-in. - -#### `person_display_name` -- **Type**: string. -- **Present**: conditional — present only alongside `person_id`, and only once - the person separately asked for a display name to be shown. Setting one - never happens as part of opting in. -- **Meaning**: a name the person chose to show beside their figures. Never - derived from `person_id`, and never used to derive it. -- **If absent**: either nobody opted in at all, or they opted in without ever - setting a display name — the ordinary state, not an incomplete one. - -#### `cli_version` -- **Type**: string, semver. -- **Present**: conditional — present only on a `provenance: "local-read"` - record, stamped by `read-local-cost-use-case.ts` at the moment it stores the - record, read through the same port `current-version-adapter.ts` already - resolves the CLI's own version through. Never on a `provenance: "export"` - record: that route's records were never produced by this CLI at all — a - different process, a tool's own SDK, gone even earlier (see `provenance` - above) — so there is no version of *this tool* to name on one. -- **Meaning**: which build of the AIDD CLI stored this record — never the - framework's own version, which stored nothing, and never the plugin's, - which stamps a different field on a different artefact (see - `aidd_docs/runs/README.md`'s `plugin_version` on the `session_start` line). - Comparing this field across two records is a real answer to "did this - figure change when I upgraded" in a way neither of the other two versions - can be, since it alone names what actually computed and wrote the line. -- **If absent**: the record was stored by a build of this CLI before this - field existed. Reads as an unknown version, never as the current one or any - other guess — inventing a default here would make an upgrade comparison - silently wrong in the one place a version exists to serve it. - -### Cost and token counters (conditional) - -#### `cost_usd` -- **Type**: number, US dollars. -- **Present**: conditional. On `"request"` lines: present on every - export-route record (a log record without `cost_usd` is not a billed request - and is never turned into a record at all) and **never** present on a - local-read record for any tool measured so far — no local reader has - captured a billed amount from a tool's own file. On `"session"` lines: - present on exactly the one (of six) datapoint lines per flush that carries - the cost measure. -- **Meaning**: the billed amount for this request, or this flush window's delta. -- **If absent**: on a local-read `"request"` line, this route cannot see a - billed amount for this tool at all — see the coverage table. On a - `"session"` line, this is one of the other five datapoints in the flush, not - the cost one. - -#### `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` -- **Type**: number. -- **Present**: conditional, and independently per field. On `"request"` lines: - Claude Code (both routes) reads all four together or none — a partial - `usage` object yields no record at all, rather than a record with a missing - counter silently read as zero. Codex reads each independently: a counter a - turn never reported (Codex sometimes omits `cache_write_input_tokens` - entirely, rather than sending zero) stays unset on that record rather than - being summed in as a fabricated zero. On `"session"` lines: exactly one of - these four fields is present per line — see "One line per datapoint, never - merged" — the other three, plus `cost_usd` and `active_time_s`, are absent on - that same line. -- **Meaning**: token counts for the request or the flush delta, normalized to - mean the same thing across tools (OpenAI's Responses API convention makes - Codex's raw `input_tokens` *inclusive* of its cached figure; this field - subtracts the cache figure out, matching Claude Code's already-exclusive - convention). -- **If absent**: this specific counter has no known value for this record — a - fact distinct from a stored `0`, which means the tool reported the counter - as exactly zero. - -#### `model` -- **Type**: string. -- **Present**: conditional — present when the producing route names a model - for this record (Claude Code, on both routes and both kinds; Codex, on - `"request"` lines via its own `turn_context`). -- **Meaning**: the model identifier the tool itself used, unmodified. -- **If absent**: this route did not carry a model name for this record. - -#### `effort` -- **Type**: string. -- **Present**: conditional — present when the route carries it (Claude Code, - both routes; Codex, local read). -- **Meaning**: the tool's own effort/reasoning-level setting for the request. -- **If absent**: not carried by this tool's route. - -#### `speed` -- **Type**: string. -- **Present**: conditional — measured so far only on Claude Code's export - route. -- **Meaning**: the tool's own speed tier for the request. -- **If absent**: not carried by this tool's route. - -#### `query_source` -- **Type**: string. -- **Present**: conditional — measured so far only on Claude Code's export - route (values seen: `"main"`, `"sdk"`, `"agent:builtin:general-purpose"`). -- **Meaning**: what originated the request within the tool (its own - main loop, its SDK, a named built-in agent). -- **If absent**: not carried by this tool's route. - -#### `agent_name` -- **Type**: string. -- **Present**: conditional — present when the record is a subagent's own - request. On Claude Code: set from the export's `agent.name` attribute, and - from the local transcript's `attributionAgent` field when the transcript - line is itself marked as a subagent line (`isSidechain: true`). -- **Meaning**: which named subagent made this request. -- **If absent**: for Claude Code, this was the main agent's own request, not a - subagent's. For every other tool measured so far, this field is never set at - all — its route does not name subagents as a concept, so its absence there - says nothing about whether one ran. - -#### `prompt_id` -- **Type**: string. -- **Present**: conditional — Claude Code only, and only where its transcript - lets the prompt be resolved. -- **Meaning**: the prompt this billed call belongs to. A billed call and the - prompt that caused it never share a transcript line: measured on a real - 810-record session, zero lines carry both `requestId` and `promptId`, only - `type: "user"` lines carry the second, and all 209 lines bearing counters - reach one by following `parentUuid` — three hops in the median. The reader - walks that chain and stores what it finds. -- **Why it exists**: the run journal writes the same identifier on `step_start` - (Claude Code hands its hooks `prompt_id`, stored there under the name - `turn_id`). Matching the two joins a step to a record exactly, rather than - inferring it from which interval each moment happens to fall in — the only - route that stays true when two tasks advance at once, since two prompts remain - two prompts however their moments overlap. Two tasks inside **one** prompt stay - indivisible: a billed amount cannot be split without inventing a ratio. -- **If absent**: the chain reached no line naming a prompt — a transcript - truncated mid-write, or a host whose files carry no such identifier, which is - every tool but Claude Code today. Never read as "no prompt ran". - -#### `prompt_skill` -- **Type**: string. -- **Present**: conditional — Claude Code only, and only where a `Skill` call was - made inside the record's own prompt. -- **Meaning**: the skill that call invoked. The same fact the run journal writes - as `step_start`'s `turn_id`, read from the transcript instead of from a hook. - The first call wins where a prompt made several: a prompt that invokes two - skills invoked the second from inside the first, and it is named for the work - it began. -- **Why it exists**: the report never re-reads a transcript — it reads this sink - and the journals beside it — so an observation only a transcript holds has to - be written down when it is read or it is gone. It names a step for a session - the journal never saw, which is every session that ran before the hook was - installed. Measured on one machine: 28 such prompts across 22 days, 318 records - named by that route and by nothing else. -- **Scoped to one transcript**: Claude Code writes a session's subagents to their - own files, and a prompt is often spread across several — measured on one - machine, 1,038 of 5,564 prompts appear in more than one file. A record names the - first skill invoked inside its prompt *in the file it sits in*. A subagent that - invoked its own skill did that work under that skill; merging the files first - would have to pick one answer for both, and neither is true of both. -- **Not a duplicate of `step`**: that one reads `attributionSkill`, which Claude - Code writes per message — exact where it appears and sparse where it does not. - Measured inside the window one skill demonstrably ran: 142 lines carry counters - and 20 carry that field. Its absence is therefore not the tool saying no skill - ran, and naming the skill a prompt invoked contradicts nothing it states. -- **Never a judgement**: which step a record belongs to is derived fresh on every - report, from this and from the journal together. The journal wins where both - name a skill for the same prompt — it was written by a hook the host fired, - where this is read back afterwards. -- **If absent**: the record's prompt invoked no skill, the chain reached no - prompt at all, or the tool is not Claude Code. Never read as "no skill ran". - -#### `duration_ms` -- **Type**: number. -- **Present**: conditional — measured so far only on Claude Code's export - route. -- **Meaning**: the request's own wall-clock duration, in milliseconds. -- **If absent**: not carried by this tool's route. - -#### `active_time_s` -- **Type**: number. -- **Present**: conditional — the field target of exactly one `"session"`-kind - metric measure, measured so far only for Claude Code - (`claude_code.active_time.total`). Never present on any `"request"` line, on - any tool. -- **Meaning**: seconds of active engagement Claude Code measured during this - flush window — not wall-clock time, and not a per-request figure. -- **If absent**: no `"request"` line carries this at all — it exists solely as - a `"session"`-kind measure; on a `"session"` line, this is one of the other - five datapoints in the flush, not the active-time one. - -#### `event_timestamp` -- **Type**: string, ISO 8601. -- **Present**: on every route measured so far, from its own source: - - **Export**, both kinds: the OTLP record's own `timeUnixNano` (nanoseconds - since the epoch, converted here to milliseconds). The `event.timestamp` - attribute is read in preference when a payload carries one, but no captured - payload ever has. - - **Claude Code, local**: the transcript line's `timestamp` field. - - **Codex, local**: the turn's own *start*, from the `turn_context` event — - not a moment inside the turn. A record spans a whole turn, so a moment - inside it would claim a precision the record does not have. - - **OpenCode, local**: the message's `time.created`, in epoch milliseconds. - Not `time.completed`, which is absent on some counted messages — a field - that sometimes means "started" and sometimes "finished" is worse than one - that always means the same thing. -- **Meaning**: when the work this record measures happened. Two consumers rely - on it and they are separate: attributing a record against a run-journal step - interval when `step` is not already tool-stated, and placing the record in a - reporting period. -- **If absent**: two things become impossible, and neither may be substituted - for. The record can no longer be attributed via a journal interval (only via - a tool-stated `step`, if one exists), so it falls back to - `step_attribution: "unattributed"`. And it belongs to **no period**: the only - other moment available is the day file it was appended to, and that is when - the record was received, not when the work ran — a session read locally days - after it happened lands in the day file for the day it was *read*. A consumer - reports such records as undated; it never places them by their day file. - -#### `event_sequence` -- **Type**: number. -- **Present**: conditional — measured so far only on Claude Code's export - route. -- **Meaning**: a monotonic counter the tool emits alongside its events. -- **If absent**: not carried by this tool's route. - -## Per-tool coverage - -Coverage is not uniform across tools, and it is not uniform across routes for the -same tool. A tool absent from one route is not a zero for that route — it is -"not covered," and a consumer should print it that way rather than infer a zero -from silence. - -| Tool | Export route (removed — historical only) | Local-read route | -| ---- | ------------- | ------------------ | -| **Claude Code** | Was declared and measured: full request-level counters via `/v1/logs`, plus the six `"session"`-kind delta metrics via `/v1/metrics` every 10 seconds. `cost_usd` was only ever available through this route — no local file carries it. Also the only route ever measured naming `billed_request_id` (its `request_id` attribute). | Declared and measured: complete token counters per assistant message, keyed on `requestId`. Step is stated by the tool itself (`attributionSkill`), exact per message — the strongest attribution any tool or route offers. No `cost_usd`. Also names `billed_request_id` (the same `requestId`). **The one tool ever measured by both routes at once**: while the export route still ran, both routes could append a record for the same billed call under two different `turn_id`s (`prompt.id` there, `requestId` here) — a consumer reading a day file spanning that period still collapses on `billed_request_id` before summing, or every figure doubles. See "A third way to double count." | -| **Codex** | Was declared (`conversation.id` measured, zero-token, to verify the identifier only). Turn identifier and any metrics export stayed unmeasured — no counters, no cost, ever flowed through this route. | Declared and measured: complete counters per turn, keyed on `turn_id`, from the rollout's `token_count` events paired with the preceding `turn_context`. A turn read while its session is still running is stored with whatever counters had arrived so far, and corrected — never edited, a second line — once a later read brings a larger reading of the same `turn_id`; see "A re-read appends unless matched." No tool-stated step — attribution is only ever a run-journal interval, or unattributed. No `cost_usd`. | -| **OpenCode** | Unmeasured — no export payload has ever been captured for this tool. | Declared and measured, via `opencode export --sanitize`: counters per request (message), keyed on the message's own `id`. No established join to a run-journal entry — no captured hook or plugin payload has ever carried OpenCode's own session identity, so nothing exists to join on; these figures answer only what a session consumed, alone. `info.cost` is deliberately never read: it is `0` in every message captured, and its denomination (which currency, computed vs. billed) has never been established — a figure whose meaning is unknown is worse than an absent one. Records carry `event_timestamp` from the message's `time.created`, so they can be placed in a period; step attribution stays out of reach regardless, since there is no join to a run journal to attribute against. | -| **Copilot** | Was declared (`gen_ai.conversation.id` measured, zero-credit, to verify the identifier only) — but that attribute lived on the `invoke_agent` *span*, not on a log record or a metric, and the receiver only ever listened on `/v1/logs` and `/v1/metrics`. Limited to those two paths, it never saw the one attribute that identified a Copilot session, so this route yielded nothing in practice for as long as it existed. | Supported at **session** granularity only: `session.shutdown` in `~/.copilot/session-state//events.jsonl` carries input, output, cache read and cache write together, once, for the whole session. Nothing in its files counts a single request, so it yields a `session` record and never a `request` one, and no amount can be placed inside a step. Read `tokenDetails`, never `usage` — the latter is inclusive of cache writes where every other reader's input is exclusive. No model is stamped: `currentModel` names the session's last model, not the one that spent. Separately, its file's own `cost` field is denominated in premium requests, not currency, so it could not be treated as `cost_usd` even where it is present. | -| **Cursor** | Was unmeasured — no payload was ever captured. Cursor's own documentation names `cursor.conversation.id`, but a name read from documentation is a guess, and enabling that export to verify it was a team setting on an Enterprise plan, in beta, that nobody outside a Cursor admin could turn on — so it stayed declared unmeasured rather than declared from an unverified guess. | Unsupported (probed): Cursor writes no token count in any file it produces — there is nothing on disk for a local reader to find. | - -Cursor was the one tool uncovered by both routes even before the export route -was removed: its export was never enabled to measure, and its local files -carry nothing to read. - -### Attributing records to a task - -A record carries no task identity, on any route. A task is derived by whatever -reads the records, from two kinds of line the run journal records beside them. -That derivation is deliberately not stored: a conclusion frozen at write time -cannot be revised, while a derivation re-runs over every past session the day -it changes. - -**A written file.** The journal hook reads a written path from the tool's own -hook payload, and only Claude Code's carries one in a readable form: Copilot's -and Cursor's were never captured doing so, and Codex writes through an -`apply_patch` command string that would have to be parsed rather than read. A -session whose journal names a written path this way belongs, as a whole, to -whatever task that path resolves to. - -**A declared ticket.** `task_declared` records that a tool call's own -arguments named a file under a task folder — the same move `step_start` -already makes for which skill is running, and it asks nothing of a payload's -shape. It reaches every host the journal hook dispatches a tool-call event -for, which today is every declared host, OpenCode included as of 2026-08-31: -a bounded measurement (three further sessions, varying the model) found that -a completed tool part's own arguments do reach its plugin's `event` hook, and -`hooks/opencode-plugin.js` joins one the same way every other host's own hook -already does — see `scripts/__tests__/fixtures/README.md`, "OpenCode's tool -part," for what was run and what arrived. A declaration is an interval, not -a whole-session fact — it opens where the tool call happened and -closes at whichever of a later declaration or a turn boundary comes next, or, -left open, at the last moment that session's journal actually recorded. Only a -record whose own moment falls inside that interval belongs to the task by -this route; the rest of the session falls back to whether it wrote into the -folder, exactly as before. - -A session on a tool that produces neither kind of line is attributable to a -**period** and, where a journal covers it, to a **step** — but never to a -task. A consumer prints that as a limit of the tool, exactly as it prints "not -covered": a session with no task is not a session that touched nothing. - -The Copilot denomination is measured, though not from anything in this -repository — it comes from reading that tool's own session files, and is -recorded here so the claim is auditable rather than taken on trust. Across -fourteen local sessions, `modelMetrics..requests.cost` sits at `0.33` -for every single-request `claude-haiku-4.5` session while `totalNanoAiu` -ranges from 2.04 to 2.95 billion and output ranges from 46 to 154 tokens; -a five-request `gpt-5-mini` session reads `0`. The figure tracks request -count times a per-model multiplier and is invariant to consumption, which is -what makes it premium requests rather than currency. - -## Consuming a session correctly - -To compute one session's true totals from a set of stored records: - -1. Group records by matching `tool` and `vendor_id` — that pair names one real - session, regardless of which `provenance` produced any individual record. -2. Within that group, collapse every `kind: "request"`, `provenance: "local-read"` - record sharing a `turn_id` into the one carrying the largest `input_tokens` + - `output_tokens` + `cache_read_tokens` + `cache_creation_tokens` — never a sum - of the group, which would state a combination of counters the tool's own file - never actually reported together. This is what a still-open turn re-read more - than once (Codex, so far) leaves behind — see "A re-read appends unless - matched." Restricted to `kind: "request"` and `provenance: "local-read"`: a - `"session"`-kind turn (Copilot's) is a one-shot total with nothing to - collapse, and an export-route `turn_id` is a prompt id several distinct - billed calls can share, so applying this step there would merge them. -3. Then collapse every `kind: "request"` record sharing a `billed_request_id` - into one before summing anything — see "A third way to double count: one - billed call, seen by both routes." A record with no `billed_request_id` is - never collapsed with another. Order between steps 2 and 3 does not matter — - the two key on disjoint fields — but doing step 2 first means a still-open - local-read turn is already down to one record before it is ever compared - against the export route's record for the same call. -4. Sum `cost_usd`, `input_tokens`, `output_tokens`, `cache_read_tokens`, and - `cache_creation_tokens` from the collapsed `kind: "request"` records in that - group only. Never include `kind: "session"` records in this sum. -5. Sum `active_time_s` from `kind: "session"` records in that group only — no - `"request"` record carries it. -6. Do not key anything else on `turn_id` beyond what it is documented for here: - it is a write-time match-and-correct key for local-read re-reads, not a - unique identifier for a billed request. `billed_request_id`, not `turn_id`, - is what step 3 above collapses on, precisely because `turn_id` lacks that - guarantee. -7. Where a tool's row above says a route is not covered, or covered without an - amount, report that plainly rather than defaulting the missing figure to - zero. diff --git a/aidd_docs/runs/.gitkeep b/aidd_docs/runs/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/aidd_docs/runs/README.md b/aidd_docs/runs/README.md deleted file mode 100644 index 2be3e375f..000000000 --- a/aidd_docs/runs/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# aidd_docs/runs - -Where the run journal's records land once AIDD telemetry is turned on. This directory being present or committed is **not the permission**. The single authoritative switch is `.aidd/config.json`'s `telemetry.enabled`, read by `plugins/aidd-telemetry/hooks/journal.js` at the point of every write, never cached across a session. With that switch on, `aidd_docs/runs/` is created on demand if it does not already exist; with it off, no record lands here regardless of whether this directory exists. Records are ignored by git (see `.gitignore`), so cloning the repository never carries anyone's session history. - -## Shape - -One file per session: `__.jsonl`. One JSON object per line, appended and never rewritten — a JSON object is a closed block that can only be rewritten whole, so this is what lets a session leave two hundred observations at the cost of two hundred appends instead of two hundred rewrites, and what lets a process that dies mid-write lose at most the one line it was writing. - -`schema_version: 2` on the `session_start` line. Version 1 was a single mutable ten-key object per session, rewritten on every turn (`ended_at`, `tasks[]`, `parent_run_id` among its fields) — replaced because a value frozen at write time cannot be revised, and the hook was writing conclusions (an interval a task attached to, a session's end time) instead of observations. - -Every line carries `at` (ISO 8601, UTC, second precision) and `type`: - -| `type` | Carries | Fired by | -| --- | --- | --- | -| `session_start` | `schema_version`, `run_id`, `project_id`, `project_remote`, `tool`, `vendor_id`, `vendor_field`, plus `worktree_id` and `worktree_repo_id` when the session ran in a linked git worktree, plus `plugin_version` when the hook could read its own manifest | SessionStart | -| `turn_end` | `prompt_id` when the host provides one, omitted otherwise | Stop | -| `file_written` | `path`, repository-relative and `/`-separated, plus `source` (`"tool-stated"` \| `"observed"`) | PostToolUse, for a write that lands inside a task folder | -| `task_declared` | `path`, repository-relative and `/`-separated | PostToolUse, for a call whose own arguments name a file under a task folder | -| `step_start` | `skill` (sanitised as a value, never as a path segment), plus `turn_id` when the host provides one | PostToolUse, for a call that names a skill being run | -| `step_end` | `skill`, sanitised the same way | PostToolUse, for a call whose own arguments carry `aidd:step-end ` — the skill saying its own work is over, since no host emits that | -| `scan_truncated` | `cap`, `scanned` | Stop, when the sweep for files a task folder gained since the turn began hit its budget before finishing — so a reader can tell "nothing else changed" from "the walk gave up before it could tell" | - -`step_start` is the line that names which skill was running, and the one this table most -recently caught up on documenting: complete coverage of what the journal writes is the -whole reason a report can attribute a token to a step at all. - -`step_end` is its counterpart, and it exists because nothing else can supply it. A `Skill` -tool call's own `tool_result` comes back about a tenth of a second later — that is the -dispatch, not the completion — so no hook can observe when a skill finished. Without a stated -end a reader must close a step at the next `step_start` or `turn_end`, and a `turn_end` is a -pause: a skill working across three prompts is credited with its first turn and nothing -after. So the skill declares its own end through a tool call it makes, carrying -`aidd:step-end ` in that call's arguments, and the hook — which alone holds the -session id and the working directory — writes the line. It is read out of free-form arguments -exactly as `task_declared` is, so every host that forwards tool arguments is covered rather -than one. - -The line names its skill, never only a moment. An end closes only the interval its own skill -opened: closing "whatever is open" would close the wrong one the moment a skill invokes -another, and an end naming a skill the session never started closes nothing at all rather -than truncating whatever was running. Like `task_declared`, it restores the run file's mtime -after appending, so it can never push the write watermark past a file the turn should still -observe. - -`plugin_version` is this plugin's own version, from `.claude-plugin/plugin.json` — read -once per process by `lib/plugin-version.cjs`, never per line, and stamped only on -`session_start`, the one line that names the session, never repeated on every later line. -Never the framework's own version, and never the CLI's — the CLI stamps a different field -on the record it stores instead, `cli_version` (see `aidd_docs/product/metrics-contract.md`) -— two different producers, two different artefacts, two different values. A manifest this -hook cannot read costs the version, never the line: `session_start` is written with -`plugin_version` omitted rather than the hook failing, and a line written before this field -existed is read the same way, as an unknown version, never as a default. - -`file_written`'s `source` says how the path came to be known. `"tool-stated"` is a path the -host handed over directly — exact, with no false positive. `"observed"` is a file that -changed inside a task folder while the session was running, the only way a write made -through a shell command or an `apply_patch` becomes visible at all — and it can, in -principle, catch a file something else on the machine wrote in the same window. A reader -that must not risk that filters on this field. - -`worktree_id` is git's own name for the linked worktree the session ran in, and `worktree_repo_id` names the repository those worktrees share — read from one `git rev-parse`, never from an agent runner's environment variable, which names that runner's concept rather than the repository's. A plain checkout, the common case, carries **neither key at all**: absent, never `null` and never `""`, because an empty string would gather every plain checkout into one group as though they were the same worktree. - -Neither `file_written` nor `task_declared` ever carries a `task_id`: task identity is a derivation from the path, and derivations belong to whatever reads the log, not to the hook that writes it. `task_declared` differs from `file_written` in what it takes as evidence, not in what it stores: a mention in a tool call's arguments (a read, an edit, a shell command line) rather than a payload naming a write outright - the move that reaches a task on a tool whose payload never hands over a written path at all. A reader turns a run of `task_declared` lines into a bounded interval, closed by whichever of a later declaration or a `turn_end` comes next; see `aidd_docs/product/metrics-contract.md`'s "Attributing records to a task". - -Whether any of these records is ever shared beyond the machine that wrote it is undecided. Nothing here sends anything anywhere. - -## The one marker outside a session file - -A payload that fires but matches no declared host writes a seventh, different kind of -line: `{ "type": "unrecognised_payload", "at": ... }`, to its own fixed file, -`_unrecognised.jsonl` — never to a `__.jsonl`, since an unrecognised -payload carries no session identity to name one by. That file holds at most one line, -overwritten on every occurrence rather than appended to: it is a marker that this fired at -all, not a log of how often. `aidd telemetry check` reads it to tell "a hook that never -fires" apart from "a hook that fires from a host this build does not yet declare." diff --git a/aidd_docs/tasks/2026_09/2026_09_01_fewer-verbs/frame.md b/aidd_docs/tasks/2026_09/2026_09_01_fewer-verbs/frame.md index 2a42574d0..e452194e3 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_fewer-verbs/frame.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_fewer-verbs/frame.md @@ -1,3 +1,7 @@ +--- +status: superseded +--- + # Fewer verbs ## The ask diff --git a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-1.md index fa63cfc5e..5f7abbfd1 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-1.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-1.md @@ -1,5 +1,5 @@ --- -status: pending +status: superseded --- # Instruction: the flow, read from what is already there diff --git a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-2.md index bb5904995..3e85b693f 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-2.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/phase-2.md @@ -1,5 +1,5 @@ --- -status: pending +status: superseded --- # Instruction: each producer stamps its own version diff --git a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/plan.md b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/plan.md index ee3f70f0d..7e17f6e38 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/plan.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/plan.md @@ -1,6 +1,6 @@ --- objective: "A period breaks down by the orchestrated run that produced it, derived from the journal as it stands, and every line names the version that wrote it." -status: pending +status: superseded --- # Plan: the flow, and who wrote what diff --git a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/spec.md b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/spec.md index 947c11882..789462e17 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/spec.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_flow-and-versions/spec.md @@ -1,3 +1,7 @@ +--- +status: superseded +--- + # A period breaks down by the flow that ran, and every line says which version wrote it ## Target diff --git a/aidd_docs/tasks/2026_09/2026_09_01_one-place-per-nature/frame.md b/aidd_docs/tasks/2026_09/2026_09_01_one-place-per-nature/frame.md index b5853480b..74f2429f8 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_one-place-per-nature/frame.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_one-place-per-nature/frame.md @@ -1,3 +1,7 @@ +--- +status: superseded +--- + # One place per nature ## The ask diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-1.md index d291dd502..c39a823fc 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-1.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-1.md @@ -1,5 +1,5 @@ --- -status: pending +status: superseded --- # Instruction: a folder says what it delivers diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-2.md index db1865ca5..3f2937e0c 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-2.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-2.md @@ -1,5 +1,5 @@ --- -status: pending +status: superseded --- # Instruction: a period breaks down by it diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-3.md index f9717dece..28da1a021 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-3.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/phase-3.md @@ -1,5 +1,5 @@ --- -status: pending +status: superseded --- # Instruction: the skills that open a folder write it diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/plan.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/plan.md index 4acc56adf..969976bfb 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/plan.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/plan.md @@ -1,6 +1,6 @@ --- objective: "A task folder names the backlog item it delivers, and a period breaks down by it, reconciling with every other breakdown." -status: pending +status: superseded --- # Plan: the upward link diff --git a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/spec.md b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/spec.md index 4f234e0fd..2cab5d5a7 100644 --- a/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/spec.md +++ b/aidd_docs/tasks/2026_09/2026_09_01_the-upward-link/spec.md @@ -1,3 +1,7 @@ +--- +status: superseded +--- + # A task folder says which backlog item it delivers, and a period breaks down by it ## Target diff --git a/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/plan.md b/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/plan.md index 2bde9bb9b..0f1bfd344 100644 --- a/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/plan.md +++ b/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/plan.md @@ -1,5 +1,5 @@ --- -status: planned +status: superseded backlog: ai-driven-dev/framework#703 spec: ./spec.md --- diff --git a/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/spec.md b/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/spec.md index ab21e2f7c..e226acfc3 100644 --- a/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/spec.md +++ b/aidd_docs/tasks/2026_09/2026_09_02_install-surfaces-agree/spec.md @@ -1,5 +1,5 @@ --- -status: framed +status: superseded backlog: ai-driven-dev/framework#703 branch: fix/install-surfaces-agree base: next@627408fb diff --git a/aidd_docs/tasks/2026_09/2026_09_03_telemetry-at-scale/measurements.md b/aidd_docs/tasks/2026_09/2026_09_03_telemetry-at-scale/measurements.md index a5a141972..8fdca6d55 100644 --- a/aidd_docs/tasks/2026_09/2026_09_03_telemetry-at-scale/measurements.md +++ b/aidd_docs/tasks/2026_09/2026_09_03_telemetry-at-scale/measurements.md @@ -1,3 +1,7 @@ +--- +status: superseded +--- + # The read path, met at a hundred sessions What [#694](https://github.com/ai-driven-dev/framework/issues/694) asks to be written down diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md new file mode 100644 index 000000000..cb37504be --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/context/previous-version-leftovers.md @@ -0,0 +1,68 @@ +# What the previous telemetry left on disk + +Recorded from the tree before phase 1 deleted it (`cli/src/...`, line numbers are of that tree, recoverable from the parent of the phase-1 commit). Input for phase 8 (`on` cleans, `forget` removes). + +## Sink directory (day files) + +Source: `contexts/telemetry/infrastructure/telemetry-sink-adapter.ts`. + +- Resolution (`:101-104`): `named = process.env.AIDD_TELEMETRY_DIR`; `legacy = userConfigDir ?? process.env.AIDD_USER_CONFIG_DIR`; `rootDir = named ?? join(legacy ?? defaultConfigDir(), "telemetry")`. +- `defaultConfigDir()` (`:55-64`): not win32 -> `join(resolveHomeDir(), ".config", "aidd")` (`legacyConfigDir`, `:44-46`); win32 -> legacy dir when `/telemetry/*.jsonl` exists, else `join(process.env.APPDATA, "aidd")`, else legacy dir. +- Files: one per UTC day, `YYYY-MM-DD.jsonl` (`DAY_FILE_EXTENSION = ".jsonl"` `:21`, `dayKey` = `at.toISOString().slice(0, 10)` `:27-29`), appended. Modes `0o600` file, `0o700` dir (`:22-23`) unless the location was user-named. +- `AIDD_USER_CONFIG_DIR` also relocates `auth.json`, `marketplaces.json`, `references.json` (`user-manifest-repository-adapter.ts:14`). + +## Identity + +Source: `contexts/telemetry/infrastructure/person-identity-adapter.ts:59`, `kernel/reading/home-dir.ts:22-27`. + +- File: `join(resolveAiddConfigDir(), "identity.json")`. +- `resolveAiddConfigDir()`: win32 with `APPDATA` -> `%APPDATA%\aidd`; otherwise `~/.config/aidd`. Never `AIDD_USER_CONFIG_DIR`. +- Shape (`person-identity-adapter.ts:41-48`): `{ person_id, origin: "minted" | "adopted", display_name?, also_me?: string[] }`, pretty JSON + newline. + +## Commit trailer delegate + +Source: `contexts/telemetry/domain/formats/commit-session-trailer.ts`. + +- Trailer token (`:11`): `AIDD-Session-Id`, written as `--trailer "AIDD-Session-Id=$session_id"`; session id = `${CODEX_THREAD_ID:-${CLAUDE_CODE_SESSION_ID:-}}`. +- Delegate file name (`:15`): `aidd-session-trailer.sh`; header `#!/bin/sh` (`:21`); second line `# Installed by \`aidd telemetry on\`, removed by \`aidd telemetry off\`.` (`:83`). +- Delegate directory (`runtime/git/git-adapter.ts:242-249`): no manager -> `git rev-parse --git-path hooks`; under lefthook/husky -> `/hooks`. +- Hook line appended to `prepare-commit-msg` (`:26-28`): `sh "" "$@"`. The hook file is `prepare-commit-msg` (`git-adapter.ts:24`), made executable. +- Lefthook snippet (`:42-49`), added to `lefthook.yml` under `prepare-commit-msg:`: + +```yaml +prepare-commit-msg: + commands: + aidd-session-trailer: + run: | + delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh" + if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi +``` + +- Husky line (`:53-56`), in `.husky/prepare-commit-msg`: + +```sh +delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh" +[ -f "$delegate" ] && sh "$delegate" "$@" +``` + +- Manager detection (`telemetry-setup.ts:176-190`): `lefthook.yml`, `lefthook.yaml`, `.lefthook.yml`, `.lefthook.yaml` -> lefthook (wins ties); `.husky` -> husky. Under a manager the CLI never edited the manager's config; it printed the snippet. + +## Run journal + +Source: `kernel/paths.ts:14-31`. + +- Directory: `aidd_docs/runs/` at the repository root above the project (`DOCS_DIR = "aidd_docs"`, `RUNS_SUBDIR = "runs"`); `AIDD_RUNS_DIR` overrides it outright. +- `RUNS_ENTRY` (`:22`) = `aidd_docs/runs/`, the `.gitignore` line `telemetry on` added (via `GitignoreUseCase`) and `manifest-gitignore-entries.ts:13` added to the managed set `[".aidd/cache/", RUNS_ENTRY, ...]`. +- Unrecognised-file quarantine name (`telemetry-evidence-adapter.ts:34`): `_unrecognised.jsonl`, inside the runs directory. + +## `.aidd/config.json` + +Source: `contexts/telemetry/domain/telemetry-switch.ts`. + +- Key `telemetry` (`:11-58`): `{ "enabled": boolean, "endpoint"?: string }`. `endpoint` was written by a since-removed `telemetry endpoint` command; `on` and `off` preserved it verbatim, nothing read it. +- Refusal variable (`:23`): `AIDD_TELEMETRY`; only the literal `"0"` refuses. +- The new version treats a bare `enabled: true` (no `version: 2`) as not opted in. + +## Claude Code settings export leftovers + +Source: `contexts/telemetry/domain/telemetry-export-leftover.ts:7-15`. `env` keys the removed `telemetry endpoint` wrote into a Claude Code settings file: `CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_METRICS_EXPORTER`, `OTEL_LOGS_EXPORTER`, `OTEL_EXPORTER_OTLP_PROTOCOL`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_RESOURCE_ATTRIBUTES`. diff --git a/cli/.claude/rules/00-architecture/0-contexts.md b/cli/.claude/rules/00-architecture/0-contexts.md index 09d716079..7223cf2c1 100644 --- a/cli/.claude/rules/00-architecture/0-contexts.md +++ b/cli/.claude/rules/00-architecture/0-contexts.md @@ -23,7 +23,6 @@ Organise by bounded context, never by layer. - Keep only shared vocabulary: types, pure helpers, typed errors. - A port two contexts need lives in `kernel/ports/`. - A domain decision is never kernel material. -- Measurement vocabulary sits in `kernel/measurement.ts`; `tools` never imports `telemetry`. ## Interior diff --git a/cli/.claude/skills/framework/SKILL.md b/cli/.claude/skills/framework/SKILL.md index cad1cfce3..573619067 100644 --- a/cli/.claude/skills/framework/SKILL.md +++ b/cli/.claude/skills/framework/SKILL.md @@ -4,7 +4,7 @@ description: > Owns the installation record and everything done to a project, under src/contexts/framework/ — the manifest aggregate, and the setup/install/restore/uninstall/doctor orchestration built on top of it. This is the only context allowed to reach `translate` and - `distribution`; `tools` is reachable from `telemetry` too. Use when adding a use-case that touches the manifest, a + `distribution`. Use when adding a use-case that touches the manifest, a setup/doctor/sync/uninstall flow, a new top-level CLI orchestration, or a launcher that runs an external binary (kanban-shaped). Do NOT use for a tool's own profile or capability classes — use `tools`. Do NOT use for the translation pipeline — use `translate`. Do NOT use for where @@ -16,7 +16,7 @@ description: > `framework` is what is posed on a project and the record of it: the manifest that tracks every installed file, and every flow that reads or changes that record — setup, doctor, sync (restore), uninstall, plugin install/update/remove, and the global chain orchestrators. It is the one -context the dependency chain lets reach every other context but `telemetry` (`framework → +context the dependency chain lets reach every other context (`framework → translate → tools → kernel`, plus `framework → distribution`), because assembling what goes on disk is exactly the job that needs all three. diff --git a/cli/.claude/skills/telemetry/SKILL.md b/cli/.claude/skills/telemetry/SKILL.md deleted file mode 100644 index 00b365c58..000000000 --- a/cli/.claude/skills/telemetry/SKILL.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -name: telemetry -description: > - Owns what a session cost and who it was for, under src/contexts/telemetry/ — reading a tool's - own local files, attributing a figure to a person, a task, a flow or a step, and the sink that - keeps records per machine. Use when adding a reader for another tool's transcript format, - changing how a figure is attributed or reported, touching the sink or the identity store, or - wiring a telemetry port. Do NOT use for what a tool declares about being measured — that is - `kernel/measurement.ts`, read via the `tools` skill. Do NOT use for installing or removing the - hook that writes the run journal into a project — that record belongs to `framework`. ---- - -# Telemetry - -`telemetry` answers two questions about work already done: what did it cost, and whose was it. -It never causes the work and never installs anything on a project's behalf. Everything it reads -already exists on disk because a tool wrote it, so the whole context is a set of readers, a set -of attribution rules, and one sink. - -Where the sink and the run journal live, what the report renders, and what each tool declares -about being measured are in `aidd_docs/memory/telemetry.md`. Read that for the facts; this page -says where new code goes and what it must not do. - -## What goes in - -| Concept | Location | -|---|---| -| A rendered answer's shape | `domain/cost-report.ts`, `domain/cost-report-envelope.ts` | -| How a figure is tied to something | `domain/step-attribution.ts`, `domain/task-attribution.ts`, `domain/flow-attribution.ts` | -| Reading one tool's own file format | `domain/formats/` (one module per tool's transcript or export) | -| A stored record and what happens to it over time | `domain/telemetry-sink-record.ts`, `domain/telemetry-sink-retention.ts` | -| Who a session was for, and how strongly that is known | `domain/person-resolution.ts`, `domain/ports/person-identity-reader.ts`, `domain/ports/person-identity-store.ts` | -| Something telemetry needs from outside itself | `domain/ports/` — declared here, satisfied at the composition root | -| The concrete reader behind one of those ports | `infrastructure/` | -| One question the `aidd telemetry` command asks | `application/` | - -## How - -- **A tool declares, telemetry reads.** What a route was measured to supply, and where a tool's - transcripts live, are declared in `kernel/measurement.ts` and filled in per tool under - `contexts/tools/domain/profiles//`. Every field there is required on purpose: a default - would be a capability nobody measured, quietly asserted for a tool nobody looked at. Never add - a branch on a tool id inside this context. -- **What telemetry needs from another context, it declares as its own port.** `installed-plugins-reader.ts` - and `ignore-entries.ts` are the pattern: telemetry states the question, `runtime/wiring/telemetry.ts` - hands it an answer, and no context reaches into telemetry in return. -- A figure with no established denomination is not an amount. A zero whose denomination was never - established, a credit and a premium request are each their own thing; conflating them is how a - report lies without ever being wrong about a number. -- An interval derived from the run journal is this CLI's inference, not the tool's statement. - Keep the two distinguishable in whatever you add — `toolStatedStep` exists for exactly that. -- Follow the use-case and port/adapter rules in `.claude/rules/00-architecture/`. - -## Public surface - -`tests/architecture/context-boundary.arch.test.ts` holds the list (`PUBLIC_MODULES.telemetry`): -the use cases the `telemetry` command drives, the shapes a rendered answer is made of, the -commit-trailer format the git adapter writes, and the two ports a caller wires a concrete -adapter into (`domain/ports/telemetry-sink.ts`, `domain/ports/version-control.ts`). Nothing -else, and no adapter. Rendering happens in `presentation/display/`, never here. - -## How it's tested - -- `tests/contexts/telemetry/` mirrors `src/contexts/telemetry/` — a format reader and an - attribution rule are unit-tier; an adapter against a real temp filesystem is integration-tier. -- A reader for a new tool's format needs a fixture captured from that tool's real output. A - format module tested only against a fixture this repository wrote proves the parser, not the - format. See the `test` skill before touching a golden snapshot. diff --git a/cli/.claude/skills/test/SKILL.md b/cli/.claude/skills/test/SKILL.md index 4c8b82927..5b91d08b8 100644 --- a/cli/.claude/skills/test/SKILL.md +++ b/cli/.claude/skills/test/SKILL.md @@ -6,7 +6,7 @@ description: > user-reported bug. Do NOT use for tier conventions, the vitest projects, doubles, fixtures or how to run a suite — those live in `aidd_docs/memory/testing.md`. Do NOT use for writing production code — use the context skill that owns the concept (`tools`, `translate`, - `distribution`, `framework`, `telemetry`). + `distribution`, `framework`). --- # Test diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md index c25fa2ffd..eabd07e49 100644 --- a/cli/ARCHITECTURE.md +++ b/cli/ARCHITECTURE.md @@ -108,7 +108,7 @@ Memory ownership (CLAUDE.md, AGENTS.md, copilot-instructions.md) is delegated to ## Hook adaptation -OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization; telemetry follows actual sessions. +OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization.
Tool compatibility and adapter contracts @@ -121,7 +121,7 @@ Hooks are authored with `${CLAUDE_PLUGIN_ROOT}`; the installer translates the ro | GitHub Copilot | yes | `${PLUGIN_ROOT}` | Declared, never observed running | | Cursor | declared | `./` | Own hook format: the converter rewrites the root to a plugin-relative path before token substitution. No plugin hook observed firing headless; what registers a plugin in Cursor's plugin directory is unknown | | OpenCode | no, by a second route | — | See below | -Runtime contracts: [hook bridge](src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts), [shared V2 adapter](assets/configs/opencode/opencode-events.js.txt), [telemetry payload adapter](../plugins/aidd-telemetry/hooks/opencode-plugin.js). The shared helper is `.opencode/hooks/opencode-events.js`. [Telemetry coverage](../plugins/aidd-telemetry/README.md#coverage) states supported versions and limitations. Unsupported hooks and skipped installation surfaces must be reported. +Runtime contracts: [hook bridge](src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts), [shared V2 adapter](assets/configs/opencode/opencode-events.js.txt). The shared helper is `.opencode/hooks/opencode-events.js`. Unsupported hooks and skipped installation surfaces must be reported.
diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index 79c9101d3..be880db13 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -47,7 +47,6 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. @aidd_docs/memory/deployment.md @aidd_docs/memory/ecosystem.md @aidd_docs/memory/project-brief.md -@aidd_docs/memory/telemetry.md @aidd_docs/memory/testing.md @aidd_docs/memory/vcs.md diff --git a/cli/README.md b/cli/README.md index eca3691d9..ea60e90e5 100644 --- a/cli/README.md +++ b/cli/README.md @@ -121,20 +121,6 @@ aidd setup --scope user --ai claude,codex,copilot --plugins none --yes | `aidd auth status` | Show which credential resolves, from where, at which level | | `aidd auth logout` | Delete the stored credential | -### Telemetry - -Opt-in, off until asked. Record shapes and report axes: [`../plugins/aidd-telemetry/README.md`](../plugins/aidd-telemetry/README.md). - -| Command | Does | -| --- | --- | -| `aidd telemetry on` | Flip the git-tracked switch on and git-ignore the run journal | -| `aidd telemetry off` | Flip the switch off, warning when a tool still exports on its own | -| `aidd telemetry check` | Say whether the chain is actually recording for this project | -| `aidd telemetry read` | Read session cost from the files the tools already wrote | -| `aidd telemetry report` | Report a period, or one task in it, along one `--axis` | -| `aidd telemetry identity` | Attach or drop this person's identifier: `use`, `off`, `link`, `unlink` | -| `aidd telemetry forget` | Irreversibly drop the journal, the stored records, and the identity file | - ## Translate `aidd translate --to --out ` converts a framework source tree into a target-native plugin tree. @@ -163,12 +149,8 @@ OpenCode and Kilo Code declare no marketplace contract, so they are flat only. E | `AIDD_TOKEN` | Token used for every fetch, ahead of any stored credential | | `AIDD_USER_CONFIG_DIR` | Relocates the user config directory outright, credentials included | | `XDG_CONFIG_HOME` | Names the config root when `AIDD_USER_CONFIG_DIR` is unset | -| `AIDD_TELEMETRY_DIR` | Names where telemetry records are kept, ahead of the config directory | -| `AIDD_RUNS_DIR` | Names the run journal directory, read alike by the CLI and the hook | | `AIDD_SKIP_UPDATE_CHECK` | Set to `1`, skips the self-update check before a command | -Share `AIDD_TELEMETRY_DIR` to pool figures across a team. Never share `AIDD_USER_CONFIG_DIR`: it also moves `auth.json`. - ## Where things live In the project: @@ -176,11 +158,10 @@ In the project: | Path | Holds | | --- | --- | | `.aidd/manifest.json` | Every owned file and its hash | -| `.aidd/config.json` | The telemetry switch, git-tracked, kept by `clean` | +| `.aidd/config.json` | A git-tracked file `clean` never removes | | `.aidd/auth.json` | The project-level credential | | `.aidd/marketplaces.json` | Marketplaces registered at project scope | | `.aidd/cache/`, `.aidd/plugin-cache/` | Fetched catalogs, built trees, plugin sources | -| `aidd_docs/runs/` | The run journal, at the repository root above the project | On the machine, under `$AIDD_USER_CONFIG_DIR`, else `$XDG_CONFIG_HOME/aidd`, else `~/.config/aidd`: @@ -188,9 +169,6 @@ On the machine, under `$AIDD_USER_CONFIG_DIR`, else `$XDG_CONFIG_HOME/aidd`, els - `marketplaces.json` and `references.json`, the user-scope registry and the projects claiming it. - `manifest.json`, what `--scope user` owns. - `cache/built//`, one built tree per CLI version. -- Telemetry records, unless `AIDD_TELEMETRY_DIR` moves them. - -The person identity file stays in the user profile and is never relocated by `AIDD_USER_CONFIG_DIR`. Per tool, the settings file the CLI writes: diff --git a/cli/aidd_docs/memory/README.md b/cli/aidd_docs/memory/README.md index bbfe824fb..20f6efae7 100644 --- a/cli/aidd_docs/memory/README.md +++ b/cli/aidd_docs/memory/README.md @@ -20,7 +20,6 @@ The list below is refreshed automatically by the memory hook. Do not edit it by - [deployment.md](deployment.md) - [ecosystem.md](ecosystem.md) - [project-brief.md](project-brief.md) -- [telemetry.md](telemetry.md) - [testing.md](testing.md) - [vcs.md](vcs.md) diff --git a/cli/aidd_docs/memory/architecture.md b/cli/aidd_docs/memory/architecture.md index 8fa139990..091081633 100644 --- a/cli/aidd_docs/memory/architecture.md +++ b/cli/aidd_docs/memory/architecture.md @@ -16,22 +16,18 @@ Every edge is a real import direction: the inter-context arrows are exactly `ALL flowchart LR Presentation["presentation"] --> Distribution["distribution"] Presentation --> Framework["framework"] - Presentation --> Telemetry["telemetry"] Presentation --> Tools["tools"] Presentation --> Translate["translate"] Runtime["runtime"] --> Distribution Runtime --> Framework - Runtime --> Telemetry Runtime --> Tools Runtime --> Translate Framework --> Translate Framework --> Distribution Framework --> Tools Translate --> Tools - Telemetry --> Tools Distribution --> Kernel["kernel"] Framework --> Kernel - Telemetry --> Kernel Tools --> Kernel Translate --> Kernel ``` @@ -39,8 +35,6 @@ flowchart LR ## Key decisions - Bounded contexts, never layers. Enforced by `tests/architecture/`, stated in `.claude/rules/00-architecture/0-contexts.md`. -- A tool declares, a context reads: measurement vocabulary lives in `kernel/measurement.ts`, so the edge runs `telemetry → tools` only. `telemetry.md` names what it reuses along it. -- Telemetry reaches no context but `tools`; what it needs elsewhere is its own port, satisfied at the composition root. - Some tools' project config is inert: Codex, Copilot and Claude load a plugin only once their own CLI registered it. A per-tool fact, verified against the real tool. - Claude's registration is driven at `--scope local`, so the hashed file keeps a single writer. - Two file regimes: what this CLI owns is regenerated; what it co-owns with a person is merged, conflicts reported. diff --git a/cli/aidd_docs/memory/cli.md b/cli/aidd_docs/memory/cli.md index cc8b3f563..e8b9c5f09 100644 --- a/cli/aidd_docs/memory/cli.md +++ b/cli/aidd_docs/memory/cli.md @@ -11,7 +11,6 @@ Thirty-three leaf commands. Read them live: `aidd --help`, then each group's. `s - `plugin install | list | remove | search | update`. - `marketplace add | list | remove | refresh | check`. - `auth login | logout | status`. -- `telemetry on | off | read | report | check | forget | identity`; `identity` carries `use | off | link | unlink`. - `translate `: author-side, converts a source into a target-native plugin tree. - `update`, aliased `upgrade`: the CLI itself. @@ -53,4 +52,4 @@ Thirty-three leaf commands. Read them live: `aidd --help`, then each group's. `s - npm bin `aidd` → `dist/cli.js`, one ESM bundle plus five JSON schemas read from disk; `files` must ship them. - `npx @ai-driven-dev/cli@latest`, or global install. Build and publish: `deployment.md`. -- `AIDD_USER_CONFIG_DIR` relocates `userConfigDir()`; the one list of what moves: `auth.json`; `marketplaces.json` and `cache/built//`; `references.json`; the `--scope user` `manifest.json`; `cache/update-check.json` and the older root `update-check.json` (`runtime/self-update/check-update-use-case.ts`); the telemetry sink root, only as a legacy fallback when `AIDD_TELEMETRY_DIR` is unset (`telemetry.md`). Never `identity.json`, which `resolveAiddConfigDir()` (`kernel/reading/home-dir.ts`) refuses this variable for. +- `AIDD_USER_CONFIG_DIR` relocates `userConfigDir()`; the one list of what moves: `auth.json`; `marketplaces.json` and `cache/built//`; `references.json`; the `--scope user` `manifest.json`; `cache/update-check.json` and the older root `update-check.json` (`runtime/self-update/check-update-use-case.ts`). diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index cffa92725..e32385e4d 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -33,14 +33,6 @@ src/ │ │ │ ├── plugins/ │ │ │ └── ports/ │ │ └── infrastructure/ -│ ├── telemetry/ # what a session cost and who it was for -│ │ ├── application/ -│ │ ├── domain/ -│ │ │ ├── formats/ -│ │ │ ├── ports/ -│ │ │ └── report/ -│ │ │ └── axes/ -│ │ └── infrastructure/ │ ├── tools/ # what a project targets, and what each target declares │ │ ├── domain/ │ │ │ ├── capabilities/ @@ -111,4 +103,3 @@ src/ | a port used by one context | that context's `domain/ports/`, adapter in its `infrastructure/` | | a port used by two | `kernel/ports/`, adapter in `runtime/` | | a runtime service | `runtime//`, wired from `runtime/wiring/` | -| a cost-report axis (its own key, sentinels, group shape, order) | `contexts/telemetry/domain/report/axes/`; the pass that fills it stays in `cost-report.ts`, which the axis reaches only through `import type` | diff --git a/cli/aidd_docs/memory/deployment.md b/cli/aidd_docs/memory/deployment.md index a9d9f1469..47adfef2c 100644 --- a/cli/aidd_docs/memory/deployment.md +++ b/cli/aidd_docs/memory/deployment.md @@ -4,7 +4,7 @@ Where the package ships and how: CI, release, and the environment it reads. ## Pipeline -- `.github/workflows/cli-ci.yml` always triggers — no `on.paths` filter, so its required check stays satisfiable on every PR. Its own `changes` job decides relevance by `git diff`-ing the base against the head in bash: `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, this workflow file itself, or `plugins/aidd-telemetry/**` excluding its own prose. Every other job — `cli-typecheck`, `cli-lint`, `cli-architecture`, `cli-coverage`, `cli-smoke`, `cli-build`, `cli-knip`, `identifier-join` (the session-identifier probe, then Claude Code's own `plugin validate` over a fresh claude build through `scripts/check-claude-accepts-build.cjs`), `cli-jscpd`, `kanban-checks`, `windows` — runs only when `changes` says relevant; `cli-mutation` is a matrix over the scopes `changes` names through `scripts/mutation-scopes-to-run.mjs`, each restoring the newest incremental file its branch or base saved, and `gate` (check name `cli / gate`) fans all of them in. `gate` is the one check the branch rulesets require (`.github/rulesets/main.json`, `next.json`). +- `.github/workflows/cli-ci.yml` always triggers — no `on.paths` filter, so its required check stays satisfiable on every PR. Its own `changes` job decides relevance by `git diff`-ing the base against the head in bash: `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, this workflow file itself. Every other job — `cli-typecheck`, `cli-lint`, `cli-architecture`, `cli-coverage`, `cli-smoke`, `cli-build`, `cli-knip`, `identifier-join` (the session-identifier probe, then Claude Code's own `plugin validate` over a fresh claude build through `scripts/check-claude-accepts-build.cjs`), `cli-jscpd`, `kanban-checks`, `windows` — runs only when `changes` says relevant; `cli-mutation` is a matrix over the scopes `changes` names through `scripts/mutation-scopes-to-run.mjs`, each restoring the newest incremental file its branch or base saved, and `gate` (check name `cli / gate`) fans all of them in. `gate` is the one check the branch rulesets require (`.github/rulesets/main.json`, `next.json`). - `.github/workflows/ci.yml` — commitlint, release-please, then the release jobs. It runs none of the checks above. - Build: `pnpm build` (tsup) → `dist/cli.js`, plus the five JSON schemas its `onSuccess` copies beside it. They are read from disk at runtime; dropping them breaks the binary. - Bundle budget in `package.json` (`bundleBudgetKB`), enforced by `scripts/check-bundle-size.mjs` after every build. That script's own header comment is the registry of every raise and reset, with the measurement behind each — read it there rather than a count here, which goes stale the next time the budget moves. diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md deleted file mode 100644 index 2f7ca1240..000000000 --- a/cli/aidd_docs/memory/telemetry.md +++ /dev/null @@ -1,39 +0,0 @@ -# Telemetry - -What a session cost, measured from files each AI tool already wrote, stored per machine. No process runs to produce it; nothing leaves the machine. Boundary for a hosted destination: [`measurement-may-reach-a-hosted-destination.md`](../../../aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md). - -## Sink - -- `TelemetrySinkAdapter` (`src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts`) resolves its root: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR` (legacy, never the variable a team shares), else the default config dir (`.config/aidd` on POSIX and on Windows with pre-existing data, `%APPDATA%/aidd` otherwise). -- One `.jsonl` day file per UTC day under `telemetry/`, append-only, `0600`/`icacls` unless the directory was user-named. -- Two record kinds (`TelemetrySinkRecordKind`, `contexts/telemetry/domain/telemetry-sink-record.ts`): `request`, `session`. Provenance: `export` or `local-read`. - -## Run journal - -- Written by the plugin hook, not the CLI: `plugins/aidd-telemetry/hooks/journal.cjs` reads stdin, detects the host, dispatches to `hooks/lib/`. -- `record.cjs` mints a run id, appends `session_start`/`turn_end`; `step-starts.cjs`, `step-ends.cjs`, `task-declared.cjs`, `file-writes.cjs` append the rest; `repo.cjs` resolves paths and tightens permissions; `trailer-repair.cjs` backs the commit trailer; `opencode-plugin.js` is OpenCode's own entry. -- Lives at the git root above the project: `kernel/paths.ts`'s `resolvedRunsDir` walks up via `repositoryRootAbove`. `AIDD_RUNS_DIR` overrides, read alike by `hooks/lib/repo.cjs` and the CLI. - -## Report - -- `aidd telemetry report` renders the axes in `ARTEFACT_AXES` (`src/presentation/display/cost-report-artefact.ts`). -- `--axis ` prints one markdown table; `--json` the envelope. Filters: `--from`, `--to`, `--days`, `--task`, `--project`, `--step`, `--model`, `--tool`. -- Envelope version `cost_report_version` (`COST_REPORT_ENVELOPE_VERSION`, `contexts/telemetry/domain/cost-report-envelope.ts`) bumps when a consumer must tell shapes apart, not per field. - -## Attribution - -- Person (`contexts/telemetry/domain/person-resolution.ts`): `mapped`, `unresolved`, `this-machine`. Identity is read and written from this machine's profile only; `aidd telemetry identity` never reads `AIDD_USER_CONFIG_DIR` or `.aidd/config.json`. -- Task, step, flow (`task-attribution.ts`, `step-attribution.ts`, `flow-attribution.ts`): declared-vs-inferred over the journal's closed intervals (`journal-intervals.ts`). -- Agent: `TelemetryRouteSupply.agentName` (`kernel/measurement.ts`); only Claude Code's reader sets it (`isSidechain`/`attributionAgent`, `contexts/telemetry/domain/formats/claude-code-transcript.ts`). - -## What a tool declares - -- `kernel/measurement.ts`'s `TelemetryLocalRead`: `declared` carries `TelemetryRouteSupply` (`tokenCounters`, `amount`, `toolStatedStep`, `agentName`), an optional `TranscriptLocation`, an optional `limitation`; `unsupported` carries a `reason`. -- `telemetry → tools` is the one allowed edge. Along it `telemetry` reuses seven `tools` public modules, listed here only: `registry.ts`, `marketplace-settings.ts`, `host-plugin-registration.ts`, `ports/host-plugin-registry-reader.ts`, and the `plugin-root-token`/`flat-hooks-merge`/`cursor-hooks-project-merge` format helpers. `domain/telemetry-setup.ts` crosses too. -- Declared: `claude`, `codex`, `copilot`, `opencode`, each in `contexts/tools/domain/profiles//profile.ts`. `unsupported`: `cursor`. Silent: `vscode`. - -## Gotchas - -- `AIDD_RUNS_DIR` answers where the journal lives; `AIDD_TELEMETRY_DIR`/`AIDD_USER_CONFIG_DIR` where the figures do. `cli.md` lists what else the latter moves. Read `plugins/aidd-telemetry/README.md` ("Share `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`") before pointing anyone at either. -- A relocated `HOME` does not relocate a real `codex` if `CODEX_HOME` is set (`testing.md`). -- A generated `prepare-commit-msg` (lefthook, husky) never calls the delegate until the printed job is added by hand; `on` and `check` name it, neither edits those files. diff --git a/cli/aidd_docs/memory/testing.md b/cli/aidd_docs/memory/testing.md index 879e659f7..f1ffe4a69 100644 --- a/cli/aidd_docs/memory/testing.md +++ b/cli/aidd_docs/memory/testing.md @@ -23,7 +23,7 @@ How this package is tested: the layers, the tools, the conventions. - A temp directory: `await mkdtemp(join(tmpdir(), "aidd--"))`, never a fixed name (a plantable symlink). The prefix is what `scripts/sweep-stale-test-dirs.cjs` reclaims. - A test name is a phrase of observable behaviour; nested `describe` reads as a sentence. `E2E:` is a recurring legacy prefix, not one to copy. - A golden snapshot is machine-independent, never derived from an absolute path: [`golden-machine-independence.md`](../../.claude/skills/test/references/golden-machine-independence.md). -- `describe.concurrent` appears in no unit or integration file; in e2e the golden and command-matrix suites use it, most `telemetry-*` suites do not. An observed split, not a rule. +- `describe.concurrent` appears in no unit or integration file; in e2e the golden and command-matrix suites use it. An observed split, not a rule. - A bug fix's review reproduces the user's scenario against the built binary: [`bug-empirical-reproduction.md`](../../.claude/skills/test/references/bug-empirical-reproduction.md). - A sandboxed run reaches no tool binary and no real profile: `tests/e2e/helpers.ts` narrows `PATH`, relocates `HOME`/`XDG_CONFIG_HOME`/`USERPROFILE`/`APPDATA`; `sandbox-reaches-no-tool-binary.e2e.test.ts` holds it. `CODEX_HOME` is never overridden: a machine setting it points a real `codex` at its own profile regardless. - Records land under `AppData\Roaming` on Windows, `.config` elsewhere. Assert through the helper. diff --git a/cli/aidd_docs/memory/vcs.md b/cli/aidd_docs/memory/vcs.md index a2fa33ecf..88b11bece 100644 --- a/cli/aidd_docs/memory/vcs.md +++ b/cli/aidd_docs/memory/vcs.md @@ -14,7 +14,6 @@ What version control means for this package. The repository's own conventions - `cli` is this package's only scope in `commitlint.config.cjs`. `domain`, `infra` and `install` are not in the enum and warn. - A `feat` or `fix` under `cli/` releases `@ai-driven-dev/cli` alone, under its own `cli-v` tag. -- A commit made by an AI session carries an `AIDD-Session-Id` trailer, appended by a `prepare-commit-msg` line that `aidd telemetry on` installs — except where lefthook or husky owns that hook and regenerates it, in which case `telemetry on` installs the delegate and prints the job to add by hand; this repository's own `lefthook.yml` carries that job. `telemetry off` removes what it installed. It is what lets a session's cost be read per commit. - The enforced header limit is 100, whatever a page says — the repository-root `commitlint.config.cjs`, which overrides nothing and so takes `config-conventional`'s default. That is the one the hook runs: `lefthook.yml`'s `commit-msg` job is `pnpm exec commitlint --edit {1}` from the repository root, and `ci.yml` passes the same file as `configFile`. The package-local `cli/commitlint.config.cjs` sets `header-max-length` to 120 and is not what either of them reads. ## Commit Strategy diff --git a/cli/biome.json b/cli/biome.json index c8a330c89..d5f673670 100644 --- a/cli/biome.json +++ b/cli/biome.json @@ -123,10 +123,6 @@ "**/runtime/**" ], "message": "domain must not import application, infrastructure, presentation or runtime" - }, - { - "group": ["**/telemetry/**"], - "message": "framework may import translate, tools and distribution, never telemetry \u2014 see context-graph.arch.test.ts" } ] } @@ -147,10 +143,6 @@ { "group": ["**/infrastructure/**"], "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" - }, - { - "group": ["**/telemetry/**"], - "message": "framework may import translate, tools and distribution, never telemetry \u2014 see context-graph.arch.test.ts" } ] } @@ -169,8 +161,8 @@ "options": { "patterns": [ { - "group": ["**/telemetry/**", "**/presentation/**", "**/runtime/**"], - "message": "framework/infrastructure may import translate, tools and distribution, never telemetry, presentation or runtime \u2014 see context-graph.arch.test.ts" + "group": ["**/presentation/**", "**/runtime/**"], + "message": "framework/infrastructure may import translate, tools and distribution, never presentation or runtime \u2014 see context-graph.arch.test.ts" } ] } @@ -198,7 +190,7 @@ "message": "domain must not import application, infrastructure, presentation or runtime" }, { - "group": ["**/framework/**", "**/distribution/**", "**/telemetry/**"], + "group": ["**/framework/**", "**/distribution/**"], "message": "translate may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" } ] @@ -225,7 +217,6 @@ "group": [ "**/framework/**", "**/distribution/**", - "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -251,7 +242,6 @@ "group": [ "**/framework/**", "**/distribution/**", - "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -283,12 +273,7 @@ "message": "domain must not import application, infrastructure, presentation or runtime" }, { - "group": [ - "**/framework/**", - "**/translate/**", - "**/distribution/**", - "**/telemetry/**" - ], + "group": ["**/framework/**", "**/translate/**", "**/distribution/**"], "message": "tools imports no other context \u2014 every context that needs it imports tools, not the reverse (context-graph.arch.test.ts)" } ] @@ -316,7 +301,6 @@ "**/framework/**", "**/translate/**", "**/distribution/**", - "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -343,7 +327,6 @@ "**/framework/**", "**/translate/**", "**/distribution/**", - "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -379,7 +362,6 @@ "**/framework/**", "**/translate/**", "**/tools/**", - "**/telemetry/**", "**/manifest.js" ], "message": "distribution knows no tool, no translation and no installation record \u2014 it says where content comes from, not what is done with it (context-graph.arch.test.ts)" @@ -408,7 +390,6 @@ "group": [ "**/translate/**", "**/tools/**", - "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -439,7 +420,6 @@ "**/framework/**", "**/translate/**", "**/tools/**", - "**/telemetry/**", "**/presentation/**" ], "message": "distribution knows no tool, no translation and no installation record, and reaches no presentation (context-graph.arch.test.ts). Its runtime imports are the grandfathered exception recorded there as BASELINE, not extended here" @@ -451,91 +431,6 @@ } } }, - { - "includes": ["src/contexts/telemetry/domain/**/*.ts"], - "linter": { - "rules": { - "style": { - "noRestrictedImports": { - "level": "error", - "options": { - "patterns": [ - { - "group": [ - "**/application/**", - "**/infrastructure/**", - "**/presentation/**", - "**/runtime/**" - ], - "message": "domain must not import application, infrastructure, presentation or runtime" - }, - { - "group": ["**/framework/**", "**/translate/**", "**/distribution/**"], - "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" - } - ] - } - } - } - } - } - }, - { - "includes": ["src/contexts/telemetry/application/**/*.ts"], - "linter": { - "rules": { - "style": { - "noRestrictedImports": { - "level": "error", - "options": { - "patterns": [ - { - "group": ["**/infrastructure/**"], - "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" - }, - { - "group": [ - "**/framework/**", - "**/translate/**", - "**/distribution/**", - "**/presentation/**", - "**/runtime/**" - ], - "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" - } - ] - } - } - } - } - } - }, - { - "includes": ["src/contexts/telemetry/infrastructure/**/*.ts"], - "linter": { - "rules": { - "style": { - "noRestrictedImports": { - "level": "error", - "options": { - "patterns": [ - { - "group": [ - "**/framework/**", - "**/translate/**", - "**/distribution/**", - "**/presentation/**", - "**/runtime/**" - ], - "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" - } - ] - } - } - } - } - } - }, { "includes": ["src/contexts/*/domain/**/*.ts", "src/contexts/*/application/**/*.ts"], "linter": { diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index ccef764bf..7fed78f8f 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -17,10 +17,6 @@ ], "break": 94 }, - "telemetry": { - "mutate": "src/contexts/telemetry/**/*.ts", - "break": 93 - }, "translate": { "mutate": "src/contexts/translate/**/*.ts", "break": 85 diff --git a/cli/package.json b/cli/package.json index d88fbca50..99746b3df 100644 --- a/cli/package.json +++ b/cli/package.json @@ -69,7 +69,6 @@ "test:mutation:tools-cursor": "node scripts/run-mutation.mjs tools-cursor", "test:mutation:tools-kilo": "node scripts/run-mutation.mjs tools-kilo", "test:mutation:tools-opencode": "node scripts/run-mutation.mjs tools-opencode", - "test:mutation:telemetry": "node scripts/run-mutation.mjs telemetry", "test:mutation:translate": "node scripts/run-mutation.mjs translate", "test:mutation:distribution": "node scripts/run-mutation.mjs distribution", "test:mutation:framework": "node scripts/run-mutation.mjs framework", diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index 57dd7971a..8df948207 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -9,7 +9,7 @@ # cannot reach. # # `ALL_COMMANDS` below is compared against what `derived_leaves` reads live off the binary, -# `telemetry identity`'s four verbs counted as leaves of their own. +# nothing counted as a leaf of its own beyond a parent and its verb. set -uo pipefail @@ -27,9 +27,6 @@ ALL_COMMANDS=( "plugin remove" "plugin list" "plugin install" "plugin search" "plugin update" "marketplace add" "marketplace list" "marketplace remove" "marketplace refresh" "marketplace check" "auth login" "auth logout" "auth status" - "telemetry on" "telemetry off" "telemetry read" "telemetry report" "telemetry check" - "telemetry forget" - "telemetry identity use" "telemetry identity off" "telemetry identity link" "telemetry identity unlink" ) PASS=0; FAIL=0; SKIP=0 @@ -64,9 +61,9 @@ project_tree_hash() { ' ) } -PARENTS=" plugin marketplace auth framework telemetry " +PARENTS=" plugin marketplace auth framework " # A parent one level deeper than PARENTS, so a covered key needs three words there, not two. -GRANDPARENTS=" telemetry identity " +GRANDPARENTS=" " derive_key() { local first="$1" second="${2:-}" third="${3:-}" if [[ "$GRANDPARENTS" == *" $first $second "* ]]; then @@ -485,56 +482,6 @@ if true; then && ok ".claude/settings.local.json removed after clean" \ || bad ".claude/settings.local.json survived clean" - # ── telemetry ──────────────────────────────────────────────── - # HOME and AIDD_USER_CONFIG_DIR are both under TMPROOT, so `forget` deletes a sandbox and - # never a person's own profile. - section "telemetry" - P_TEL=$(new_project) - run "telemetry check (before anything)" "0|1" "" "$P_TEL" -- telemetry check - run "telemetry on --yes" 0 "" "$P_TEL" -- telemetry on --yes - [[ -f "$P_TEL/.aidd/config.json" ]] && ok "telemetry on writes the switch" || bad "no .aidd/config.json after telemetry on" - run "telemetry identity use" 0 "" "$P_TEL" -- telemetry identity use - run "telemetry identity off" 0 "" "$P_TEL" -- telemetry identity off - # link/unlink need a real identifier, so `--help` pins the leaf without mutating a profile. - run "telemetry identity link --help" 0 "" "$P_TEL" -- telemetry identity link --help - run "telemetry identity unlink --help" 0 "" "$P_TEL" -- telemetry identity unlink --help - run "telemetry read" 0 "" "$P_TEL" -- telemetry read - run "telemetry report" 0 "" "$P_TEL" -- telemetry report - run "telemetry off" 0 "" "$P_TEL" -- telemetry off - run "telemetry forget --yes" 0 "" "$P_TEL" -- telemetry forget --yes - - # A lefthook-owned repository regenerates prepare-commit-msg on every install, wiping any - # line `telemetry on` appends, so it must print the job to add by hand instead of promising - # a trailer. Detection reads a root marker file, never a real lefthook binary. - P_TEL_LEFTHOOK=$(new_project) - cat > "$P_TEL_LEFTHOOK/lefthook.yml" <<'LEFTHOOK_YML' -pre-commit: - commands: - example: - run: echo hi -LEFTHOOK_YML - run "telemetry on --yes (lefthook-owned hook)" 0 "prepare-commit-msg:" "$P_TEL_LEFTHOOK" -- telemetry on --yes - - # Once a manager owns prepare-commit-msg, `on` writes the delegate to the common git dir and - # ignores core.hooksPath, which husky routes under `.husky/`; `off` must resolve the same way - # or it finds nothing to delete under exactly this divergence. - P_TEL_HUSKY=$(new_project) - mkdir -p "$P_TEL_HUSKY/.husky" - cat > "$P_TEL_HUSKY/.husky/prepare-commit-msg" <<'HUSKY_HOOK' -#!/bin/sh -echo husky-owned -HUSKY_HOOK - chmod +x "$P_TEL_HUSKY/.husky/prepare-commit-msg" - (cd "$P_TEL_HUSKY" && git config core.hooksPath .husky) - run "telemetry on --yes (husky core.hooksPath)" 0 "husky" "$P_TEL_HUSKY" -- telemetry on --yes - [[ -f "$P_TEL_HUSKY/.git/hooks/aidd-session-trailer.sh" ]] \ - && ok "telemetry on writes the delegate to the common hooks dir under husky" \ - || bad "delegate missing from .git/hooks under husky's core.hooksPath" - run "telemetry off (husky core.hooksPath)" 0 "" "$P_TEL_HUSKY" -- telemetry off - [[ ! -f "$P_TEL_HUSKY/.git/hooks/aidd-session-trailer.sh" ]] \ - && ok "telemetry off removes the delegate even when core.hooksPath diverges" \ - || bad "delegate survived telemetry off under husky's core.hooksPath" - fi # ════════════════════════════════════════════════════════════════ diff --git a/cli/src/cli.ts b/cli/src/cli.ts index bcebebfc4..399853748 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -9,7 +9,6 @@ import { runMenuLoop } from "./presentation/commands/menu.js"; import { registerPluginCommand } from "./presentation/commands/plugin.js"; import { registerSetupCommand } from "./presentation/commands/setup.js"; import { registerSyncCommand } from "./presentation/commands/sync.js"; -import { registerTelemetryCommand } from "./presentation/commands/telemetry.js"; import { registerTranslateCommand } from "./presentation/commands/translate.js"; import { registerUpdateCommand } from "./presentation/commands/update.js"; import { CLIOutput } from "./presentation/output.js"; @@ -40,7 +39,6 @@ registerSyncCommand(program); registerUpdateCommand(program); registerDoctorCommand(program); registerCleanCommand(program); -registerTelemetryCommand(program); // Commands already paying for network I/O, so the update-check refresh rides one of them. // `marketplace remove` is offline and `update` already resolves the latest version itself. diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts index f9c53fd2d..237c32067 100644 --- a/cli/src/contexts/framework/application/clean-use-case.ts +++ b/cli/src/contexts/framework/application/clean-use-case.ts @@ -270,7 +270,7 @@ export class CleanUseCase { } } - // `config.json` is the committed telemetry switch: a file clean did not write, so clean never + // `config.json` is a committed file clean did not write, so clean never // removes it. Everything AIDD did write must go before the emptiness check, or its own presence // blocks a removal that should happen — the registry `marketplace add` writes included. private async removeAiddState(projectRoot: string): Promise { diff --git a/cli/src/contexts/framework/application/gitignore-use-case.ts b/cli/src/contexts/framework/application/gitignore-use-case.ts index f7c2ff42e..dfbec0302 100644 --- a/cli/src/contexts/framework/application/gitignore-use-case.ts +++ b/cli/src/contexts/framework/application/gitignore-use-case.ts @@ -7,8 +7,7 @@ export class GitignoreUseCase { constructor(private readonly fs: FileReader & FileWriter) {} /** `true` when at least one of `entries` was newly appended, `false` when every one was already - * there. One caller depends on it: `aidd telemetry on` announces the journal being ignored only - * on the run that actually adds the line. */ + * there. */ async execute(projectRoot: string, entries: string[]): Promise { const gitignorePath = `${projectRoot}/${GITIGNORE_FILENAME}`; diff --git a/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts b/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts index 6e0e6afc0..f64dba090 100644 --- a/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts +++ b/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts @@ -1,4 +1,4 @@ -import { AIDD_DIR, RUNS_ENTRY } from "../../../kernel/paths.js"; +import { AIDD_DIR } from "../../../kernel/paths.js"; import { machineLocalFilesOf } from "../../tools/domain/registry.js"; import type { Manifest } from "./manifest.js"; @@ -10,5 +10,5 @@ export function aiddGitignoreEntries(manifest: Manifest): string[] { const machineLocal = manifest .getInstalledToolIds() .flatMap((toolId) => machineLocalFilesOf(toolId)); - return [`${AIDD_DIR}/cache/`, RUNS_ENTRY, ...new Set(machineLocal)]; + return [`${AIDD_DIR}/cache/`, ...new Set(machineLocal)]; } diff --git a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts index 8aed72d64..a41330a39 100644 --- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts +++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts @@ -11,7 +11,7 @@ import { readManifestFile } from "./manifest-file-io.js"; * rule, same file I/O. Only the path differs, and what a version-refusal message names to fix it. * * `delete()` removes only `manifest.json` itself, never its parent directory: `userConfigDir()` - * also holds `auth.json`, `marketplaces.json`, `references.json` and `telemetry/`, none of which + * also holds `auth.json`, `marketplaces.json`, and `references.json`, none of which * this repository owns. Pruning it once empty, as the project adapter does with `.aidd/`, would be * a live bug here. */ diff --git a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts deleted file mode 100644 index 2e3e101be..000000000 --- a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { describeError } from "../../../kernel/describe-error.js"; -import type { VersionReader } from "../../../kernel/ports/version-reader.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../kernel/tool.js"; -import { buildHostRegistration } from "../../tools/domain/host-plugin-registration.js"; -import type { HostPluginRegistryReader } from "../../tools/domain/ports/host-plugin-registry-reader.js"; -import { getAiToolConfig, resolvePluginsCapability } from "../../tools/domain/registry.js"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, -} from "../domain/formats/commit-session-trailer.js"; -import type { HookTrustReader } from "../domain/ports/hook-trust-reader.js"; -import type { InstalledPluginsReader } from "../domain/ports/installed-plugins-reader.js"; -import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; -import type { RunJournal, RunJournalReader } from "../domain/ports/run-journal-reader.js"; -import type { SessionCostReader } from "../domain/ports/session-cost-reader.js"; -import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; -import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; -import type { VersionControl } from "../domain/ports/version-control.js"; -import { resolveSessionAnchor } from "../domain/session-anchor.js"; -import { - anchorProjectElsewhere, - resolveSessionProject, - type SessionProject, -} from "../domain/session-project.js"; -import { - attributeMoment, - buildStepIntervals, - type StepAttributionSource, -} from "../domain/step-attribution.js"; -import { - diagnoseTelemetryClaims, - type TelemetryClaim, - type TelemetryClaimJournal, - type TelemetryClaimToolRead, - type TelemetryCodexHookTrust, - type TelemetryEvidence, -} from "../domain/telemetry-claim.js"; -import type { TelemetryExportLeftover } from "../domain/telemetry-export-leftover.js"; -import { - buildTelemetryAllowedSetup, - type TelemetryHostRegistrationSetup, - type TelemetryIdentitySetup, - type TelemetryPluginVersionSetup, - type TelemetryRecorderDeclarationSetup, - type TelemetrySetup, -} from "../domain/telemetry-setup.js"; - -const DEFAULT_RUNS_DIR_LABEL = "aidd_docs/runs"; - -export interface DiagnoseTelemetryUncoveredTool { - readonly tool: AiToolId; - readonly reason: string; -} - -/** What `aidd telemetry check` answers with. `gate` is mutually exclusive with `claims`: a gated - * run judges nothing. `leftoverExportConfig` and `setup` are gathered either side of the gate — a - * stale export lives in a tool's own settings file, whatever this project's switch says. */ -export type DiagnoseTelemetryResult = - | { - readonly gate: string; - readonly setup: TelemetrySetup; - readonly leftoverExportConfig: readonly TelemetryExportLeftover[]; - } - | { - readonly gate?: undefined; - readonly setup: TelemetrySetup; - readonly claims: readonly TelemetryClaim[]; - readonly uncovered: readonly DiagnoseTelemetryUncoveredTool[]; - readonly leftoverExportConfig: readonly TelemetryExportLeftover[]; - }; - -/** How far back the trailer count looks. A count rather than a date: the cost is the same on any - * repository, and enough to show whether recent commits are being stamped. */ -const COMMITS_EXAMINED_FOR_TRAILER = 20; - -export interface DiagnoseTelemetryOptions { - readonly projectRoot: string; - readonly env: NodeJS.ProcessEnv; -} - -function toClaimJournal(journal: RunJournal): TelemetryClaimJournal { - return { - vendorId: journal.session?.vendor_id, - sessionStartAt: journal.session?.at, - turnClosed: journal.boundaries.length > 0, - }; -} - -function isCovered(tool: AiToolId): boolean { - return getAiToolConfig(tool).telemetryLocalRead.kind === "declared"; -} - -function coveredTools(): readonly AiToolId[] { - return AI_TOOL_IDS.filter(isCovered); -} - -function uncoveredTools(): readonly DiagnoseTelemetryUncoveredTool[] { - return AI_TOOL_IDS.filter((tool) => !isCovered(tool)).map((tool) => { - const localRead = getAiToolConfig(tool).telemetryLocalRead; - return { - tool, - reason: localRead.kind === "unsupported" ? localRead.reason : "no reader wired yet", - }; - }); -} - -/** The plugin version the hook stamped, from the most recently opened session carrying one: an - * upgrade mid-period leaves older lines naming the older build. A session carrying none is - * skipped, never counted as an absence. */ -function pluginVersionFrom(journals: readonly RunJournal[]): TelemetryPluginVersionSetup { - const sessions = journals.map((journal) => journal.session).filter(isPresent); - if (sessions.length === 0) return { kind: "nothing-journalled" }; - const withVersion = sessions - .filter((session) => session.plugin_version !== undefined) - .sort((a, b) => Date.parse(b.at) - Date.parse(a.at)); - const newest = withVersion[0]; - return newest?.plugin_version === undefined - ? { kind: "unrecorded" } - : { kind: "recorded", version: newest.plugin_version }; -} - -function isPresent(value: T | undefined): value is T { - return value !== undefined; -} - -/** - * Gathers every claim's evidence, then hands it to the pure judge in `domain/telemetry-claim.ts`. - * Never writes: the question is only ever "would a read of this session's figures work". - */ -export class DiagnoseTelemetryUseCase { - constructor( - private readonly evidence: TelemetryEvidenceReader, - private readonly git: VersionControl, - private readonly runJournalReader: RunJournalReader, - private readonly readers: ReadonlyMap, - private readonly hookTrustReader: HookTrustReader, - private readonly personIdentityStore: PersonIdentityStore, - private readonly telemetrySink: TelemetrySink, - private readonly currentVersion: VersionReader, - private readonly installedPlugins: InstalledPluginsReader, - private readonly hostRegistries: ReadonlyMap - ) {} - - async execute(options: DiagnoseTelemetryOptions): Promise { - // Gathered before the gate and regardless of it: a stale export exports whether or not this - // project's switch is on, and setup is what a person switched off still needs to see. - const journals = await this.runJournalReader.list(); - const leftoverExportConfig = await this.evidence.findLeftoverExportConfig(options.projectRoot); - const setup = await this.gatherSetup(options, journals); - const gate = await this.gateReason(options); - if (gate !== null) return { gate, setup, leftoverExportConfig }; - const evidence = await this.gatherEvidence(options, setup.recorderDeclaration, journals); - const claims = diagnoseTelemetryClaims(evidence); - return { setup, claims, uncovered: uncoveredTools(), leftoverExportConfig }; - } - - private async gatherSetup( - options: DiagnoseTelemetryOptions, - journals: readonly RunJournal[] - ): Promise { - const [switchSetup, recorderDeclaration] = await Promise.all([ - this.evidence.readSwitchSetup(options.projectRoot), - this.evidence.readRecorderDeclaration(options.projectRoot), - ]); - return { - allowed: buildTelemetryAllowedSetup(switchSetup, options.env), - identity: await this.readIdentitySetup(), - recordsLocation: { path: this.telemetrySink.rootDir }, - recorderDeclaration, - hostRegistration: await this.readHostRegistration(options.projectRoot), - commitTrailer: await this.git.readCommitTrailerSetup( - options.projectRoot, - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - COMMITS_EXAMINED_FOR_TRAILER - ), - versions: { - cli: this.currentVersion.get(), - plugin: pluginVersionFrom(journals), - }, - }; - } - - /** Every plugin the manifest records, against what each host's registry says. Driven from the - * manifest, never from a settings file: a plugin whose marketplace does not resolve is skipped - * silently there, so a settings-first comparison would read absence as agreement. */ - private async readHostRegistration(projectRoot: string): Promise { - let recorded: Awaited>; - try { - recorded = await this.installedPlugins.read(); - } catch (error) { - // A damaged manifest throws rather than returning null: reported, never fatal, and named — - // the `recorder declared` row scans the same file's raw JSON while this goes through the - // manifest's own validation, so the two rows can disagree about one file. - return { - ...buildHostRegistration([]), - manifestUnreadable: `${this.installedPlugins.path} — ${describeError(error)}`, - }; - } - if (recorded === null) return buildHostRegistration([]); - const evidence = await Promise.all( - // Filtered before the read, never after: a tool with no plugin recorded would otherwise pay - // a home-directory read whose result is thrown away on every `check`. - AI_TOOL_IDS.filter((tool) => (recorded.get(tool) ?? []).length > 0).map(async (tool) => ({ - tool, - plugins: (recorded.get(tool) ?? []).map((plugin) => ({ - name: plugin.name, - marketplace: plugin.marketplace, - })), - reading: await this.hostRegistries.get(tool)?.read(projectRoot), - declaresNativeActivation: resolvePluginsCapability(tool)?.nativeActivation != null, - })) - ); - return buildHostRegistration(evidence); - } - - // `readStrict()` throws on a damaged file, unlike the `read()` every other consumer uses: this - // caller must tell "nobody chose" apart from "could not be read", so it catches. - private async readIdentitySetup(): Promise { - const path = this.personIdentityStore.filePath; - try { - const identity = await this.personIdentityStore.readStrict(); - return { attached: identity !== null, path, readable: true }; - } catch { - return { attached: false, path, readable: false }; - } - } - - private async gatherEvidence( - options: DiagnoseTelemetryOptions, - recorderDeclaration: TelemetryRecorderDeclarationSetup, - journals: readonly RunJournal[] - ): Promise { - const currentSessionId = resolveSessionAnchor(options.env); - const unrecognisedPayload = await this.evidence.readUnrecognisedPayload(options.projectRoot); - const hookTrust = await this.resolveHookTrust(options.env, currentSessionId); - const toolReads = await this.gatherToolReads(journals); - const anchorInAnotherProject = await this.resolveAnchorProject(currentSessionId, journals); - return { - journals: journals.map(toClaimJournal), - toolReads, - runsDirLabel: DEFAULT_RUNS_DIR_LABEL, - currentSessionId, - unrecognisedPayloadAt: unrecognisedPayload?.at, - hookTrust, - ...(anchorInAnotherProject === null ? {} : { anchorInAnotherProject }), - recorderDeclared: recorderDeclaration.declared, - recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0, - foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(), - }; - } - - // Stops the run before any claim is evaluated: neither fact is evidence about the hook, - // both are facts about whether there is anything here for it to have written. - private async gateReason(options: DiagnoseTelemetryOptions): Promise { - if (!(await this.evidence.isTelemetryEnabled(options.projectRoot, options.env))) { - return "measurement is off — nothing to check until it is turned on"; - } - if (!(await this.git.isRepository(options.projectRoot))) { - return ( - "not a git repository — the hook has nowhere to write here, not a hook that failed " + - "to fire" - ); - } - return null; - } - - /** Asked only of an anchor that left no run file here: one journalled here is ours already. */ - private async resolveAnchorProject( - currentSessionId: string | undefined, - journals: readonly RunJournal[] - ): Promise { - if (currentSessionId === undefined) return null; - if (journals.some((journal) => journal.session?.vendor_id === currentSessionId)) return null; - const here = journals - .map(resolveSessionProject) - .filter((project): project is SessionProject => project !== null); - if (here.length === 0) return null; - const stored = await this.telemetrySink.readRecordsForVendor(currentSessionId); - return anchorProjectElsewhere(here, stored); - } - - // Only Codex gates a hook behind a trust grant it can decline in silence: a session - // running under any other tool has nothing to read here, and asks nothing of it. - private async resolveHookTrust( - env: NodeJS.ProcessEnv, - currentSessionId: string | undefined - ): Promise { - if (env.CODEX_THREAD_ID === undefined || currentSessionId === undefined) return undefined; - return this.hookTrustReader.read(); - } - - private async gatherToolReads( - journals: readonly RunJournal[] - ): Promise { - const covered = coveredTools(); - const reads: TelemetryClaimToolRead[] = []; - for (const journal of journals) { - const sessionId = journal.session?.vendor_id; - if (sessionId === undefined) continue; - const intervals = buildStepIntervals(journal); - for (const tool of covered) { - reads.push(await this.readOneTool(tool, sessionId, intervals)); - } - } - return reads; - } - - // A reader's contract promises never to throw, and this catches anyway: a diagnostic that - // crashed on one unreadable file would answer nothing about every other claim. - private async readOneTool( - tool: AiToolId, - sessionId: string, - intervals: ReturnType - ): Promise { - const reader = this.readers.get(tool); - const hasIntervals = intervals.length > 0; - if (!reader) return { tool, sessionFound: false, hasIntervals, records: [] }; - try { - const result = await reader.read(sessionId); - const records = result.records.map((record) => stampAttribution(record, intervals)); - return { tool, sessionFound: result.sessionFound, hasIntervals, records }; - } catch (error) { - return { - tool, - sessionFound: false, - hasIntervals, - records: [], - error: error instanceof Error ? error.message : String(error), - }; - } - } -} - -function stampAttribution( - record: { readonly step?: string; readonly event_timestamp?: string }, - intervals: ReturnType -): { readonly stepAttribution: StepAttributionSource } { - if (record.step !== undefined) return { stepAttribution: "tool-stated" }; - return { stepAttribution: attributeMoment(intervals, record.event_timestamp).source }; -} diff --git a/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts deleted file mode 100644 index 7042d1f8f..000000000 --- a/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { errorMessage } from "../../../kernel/describe-error.js"; -import { RUNS_ENTRY } from "../../../kernel/paths.js"; -import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; -import type { RunJournalStore } from "../domain/ports/run-journal-reader.js"; -import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; -import type { VersionControl } from "../domain/ports/version-control.js"; -import type { - TelemetryHistoryReading, - TelemetryMachineIdentityRemoval, - TelemetryMachineSinkRemoval, - TelemetryProjectJournalRemoval, - TelemetryRemovalPreview, -} from "../domain/telemetry-removal.js"; - -export interface ForgetTelemetryOptions { - readonly projectRoot: string; -} - -export interface TelemetryRemovalFailure { - readonly path: string; - readonly reason: string; -} - -export interface TelemetryRemovalOutcome { - readonly removed: number; - readonly failed: readonly TelemetryRemovalFailure[]; -} - -export interface TelemetryRemovalResult { - readonly journal: TelemetryRemovalOutcome; - readonly sink: TelemetryRemovalOutcome; - readonly identity: TelemetryRemovalOutcome; - /** Repeated from the preview: history does not become reachable by having removed the rest, - * so this is the exact same reading, not a fresh one. */ - readonly history: TelemetryHistoryReading; -} - -/** - * Shows, then removes, what this tool measured about one person, never both in the same call. - * `preview()` alone resolves every location; `remove()` takes exactly that value and resolves - * none of its own, so deleting something nobody was shown is inexpressible, not merely untested. - * Whether to call `remove()` at all is the command layer's decision; a refusal is never a throw. - */ -export class ForgetTelemetryUseCase { - constructor( - private readonly sink: TelemetrySink, - private readonly runJournalReader: RunJournalStore, - private readonly identity: PersonIdentityStore, - private readonly git: VersionControl - ) {} - - /** Resolves every location once, and touches nothing — a person sees exactly this value - * before anything is asked to go. */ - async preview(options: ForgetTelemetryOptions): Promise { - const [dayFileNames, runFileNames, isRepo, tracked, hasHistory, identityState] = - await Promise.all([ - this.sink.listDayFiles(), - this.runJournalReader.listRunFiles(), - this.git.isRepository(options.projectRoot), - this.git.listTrackedFiles(options.projectRoot, RUNS_ENTRY), - this.git.hasHistoryFor(options.projectRoot, RUNS_ENTRY), - this.identityState(), - ]); - return { - journal: { scope: "project", path: this.runJournalReader.runsDir, runFileNames }, - sink: { scope: "machine", path: this.sink.rootDir, dayFileNames }, - identity: { scope: "machine", path: this.identity.filePath, ...identityState }, - history: this.historyReading(isRepo, tracked, hasHistory), - }; - } - - private historyReading( - isRepo: boolean, - tracked: readonly string[], - hasHistory: boolean - ): TelemetryHistoryReading { - if (!isRepo) return { certainty: "none" }; - if (tracked.length === 0) return { certainty: "possible" }; - return hasHistory - ? { certainty: "committed", files: tracked } - : { certainty: "staged", files: tracked }; - } - - /** Removes exactly what `preview` resolved, never a location of its own. Every location is - * attempted whatever the others did: one failure never spares or stops the rest. */ - async remove(preview: TelemetryRemovalPreview): Promise { - const [journal, sink, identity] = await Promise.all([ - this.removeJournal(preview.journal), - this.removeSink(preview.sink), - this.removeIdentity(preview.identity), - ]); - return { journal, sink, identity, history: preview.history }; - } - - // `readStrict()` throwing is the file existing but being unreadable - exactly the file a - // person most needs named as present. `null` is the ordinary "nobody opted in" case. - private async identityState(): Promise<{ present: boolean; unreadable: boolean }> { - try { - return { present: (await this.identity.readStrict()) !== null, unreadable: false }; - } catch { - return { present: true, unreadable: true }; - } - } - - // `journal.path`, never `this.runJournalReader.runsDir` re-read here: this must act on the - // same value a person was shown. - private async removeJournal( - journal: TelemetryProjectJournalRemoval - ): Promise { - const failed: TelemetryRemovalFailure[] = []; - let removed = 0; - for (const fileName of journal.runFileNames) { - try { - await this.runJournalReader.deleteRunFile(journal.path, fileName); - removed++; - } catch (error) { - failed.push({ path: fileName, reason: errorMessage(error) }); - } - } - return { removed, failed }; - } - - // `sink.path`, for the same reason `removeJournal` uses `journal.path`: the two agree today, - // but a second computation here would be free to disagree tomorrow. - private async removeSink(sink: TelemetryMachineSinkRemoval): Promise { - const failed: TelemetryRemovalFailure[] = []; - let removed = 0; - for (const fileName of sink.dayFileNames) { - try { - await this.sink.deleteDayFile(sink.path, fileName); - removed++; - } catch (error) { - failed.push({ path: fileName, reason: errorMessage(error) }); - } - } - return { removed, failed }; - } - - // Gated on the preview's own `identity.present`, never the filesystem at removal time: a file - // that appeared after a preview said "nothing to remove" must not be deleted and counted. - private async removeIdentity( - identity: TelemetryMachineIdentityRemoval - ): Promise { - if (!identity.present) return { removed: 0, failed: [] }; - try { - const wasThere = await this.identity.forget(identity.path); - return { removed: wasThere ? 1 : 0, failed: [] }; - } catch (error) { - return { removed: 0, failed: [{ path: identity.path, reason: errorMessage(error) }] }; - } - } -} diff --git a/cli/src/contexts/telemetry/application/person-identity-use-case.ts b/cli/src/contexts/telemetry/application/person-identity-use-case.ts deleted file mode 100644 index 4971d903e..000000000 --- a/cli/src/contexts/telemetry/application/person-identity-use-case.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - EmptyDisplayNameError, - EmptyIdentifierError, - IdentityRequiredToLinkError, - UnreadableIdentityFileError, -} from "../../../kernel/errors.js"; -import type { PersonIdentity } from "../domain/ports/person-identity-reader.js"; -import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; - -export interface PersonIdentityStatusResult { - readonly filePath: string; - readonly identity: PersonIdentity | null; -} - -export interface PersonIdentityUseResult { - readonly filePath: string; - readonly identity: PersonIdentity; - /** How this machine came to carry the identifier it now carries. Three values, not two - * booleans: one this machine minted is not the same fact as one carried here from another. */ - readonly outcome: "minted" | "adopted" | "unchanged"; - /** The identifier this replaced — absent both when nothing was declared yet and when the same - * one was already in effect. Records already written keep the identifier they carry. */ - readonly replacedPersonId?: string; - /** The display name this call attached, when one was asked for. Absent when none was — - * never `""`, which would read as a name someone chose to be empty. */ - readonly displayNameSet?: string; -} - -export interface PersonIdentityOffResult { - readonly filePath: string; - /** `false` when there was nothing to withdraw. */ - readonly removed: boolean; - /** `true` when the file existed but could not be read back, and was removed anyway — `off` - * must work exactly when a damaged file would otherwise leave a person unable to withdraw. */ - readonly discardedDamaged: boolean; - /** How many identifiers `alsoMe` carried at withdrawal — `off` removes the whole declaration. - * `0` both when none were added and when a damaged file hid how many there were. */ - readonly addedIdentifiersRemoved: number; -} - -export interface PersonIdentityLinkResult { - readonly filePath: string; - readonly personId: string; - readonly identity: string; - /** `true` when `identity` already resolved to this same person before this call - a caller - * that always links first, then reports, must tell a no-op apart from a fresh write. */ - readonly alreadyListed: boolean; -} - -export interface PersonIdentityUnlinkResult { - readonly filePath: string; - readonly identity: string; - /** `false` when `identity` was never listed at all - reported as nothing to remove, - * never as a failure. */ - readonly removed: boolean; -} - -/** - * Every verb acts on the one file declaring who this machine's user is: `use` mints an - * identifier or takes one minted elsewhere, so a person reads as one across machines; - * `link`/`unlink` carry identifiers chosen elsewhere on `alsoMe`; `off` withdraws the whole - * file. Neither `use` nor `link` can verify that the person running it is who they claim. - */ -export class PersonIdentityUseCase { - constructor(private readonly store: PersonIdentityStore) {} - - async status(): Promise { - const filePath = this.store.filePath; - const identity = await this.store.readStrict(); - return { - filePath, - identity, - }; - } - - /** The one door to "which identifier am I": `identifier` absent mints, present adopts — the - * same question with and without an answer in hand, kept apart on disk by `origin`. */ - async use(options: { - identifier?: string; - displayName?: string; - }): Promise { - if (options.identifier !== undefined && options.identifier.trim() === "") { - throw new EmptyIdentifierError("use"); - } - if (options.displayName !== undefined && options.displayName.trim() === "") { - throw new EmptyDisplayNameError(); - } - const settled = await this.settleIdentifier(options.identifier); - const identity = - options.displayName === undefined - ? settled.identity - : await this.store.setDisplayName(settled.identity, options.displayName); - return { - filePath: this.store.filePath, - identity, - outcome: settled.outcome, - ...(settled.replacedPersonId === undefined - ? {} - : { replacedPersonId: settled.replacedPersonId }), - ...(options.displayName === undefined ? {} : { displayNameSet: options.displayName }), - }; - } - - /** Split from the display name because that is an independent decision: folding both into one - * body would make a rename look like a change of identity. */ - private async settleIdentifier(identifier?: string): Promise<{ - identity: PersonIdentity; - outcome: PersonIdentityUseResult["outcome"]; - replacedPersonId?: string; - }> { - const current = await this.store.readStrict(); - if (identifier === undefined) { - if (current !== null) return { identity: current, outcome: "unchanged" }; - return { identity: await this.store.mint(), outcome: "minted" }; - } - if (current !== null && current.personId === identifier) { - return { identity: current, outcome: "unchanged" }; - } - const identity = await this.store.adopt(identifier); - return { - identity, - outcome: "adopted", - ...(current === null ? {} : { replacedPersonId: current.personId }), - }; - } - - async link(identity: string): Promise { - if (identity.trim() === "") throw new EmptyIdentifierError("link"); - const person = await this.store.readStrict(); - if (person === null) throw new IdentityRequiredToLinkError(); - const alreadyListed = identity === person.personId || person.alsoMe.includes(identity); - if (!alreadyListed) await this.store.addAlsoMe(identity); - return { filePath: this.store.filePath, personId: person.personId, identity, alreadyListed }; - } - - async unlink(identity: string): Promise { - const person = await this.store.readStrict(); - const removed = person?.alsoMe.includes(identity) ?? false; - if (removed) await this.store.removeAlsoMe(identity); - return { filePath: this.store.filePath, identity, removed }; - } - - /** The one verb allowed to swallow `readStrict()`'s throw: `off` is how a person gets out, and - * a file too damaged to parse is exactly the moment withdrawing must still work. */ - async off(): Promise { - const filePath = this.store.filePath; - const { existing, discardedDamaged } = await this.readForWithdrawal(); - const addedIdentifiersRemoved = existing?.alsoMe.length ?? 0; - // Always asks the store, never decides from the read above: a file holding an empty - // `person_id` reads as "nobody chose" and would be left on disk. - const removed = await this.store.forget(filePath); - return { filePath, removed, discardedDamaged, addedIdentifiersRemoved }; - } - - private async readForWithdrawal(): Promise<{ - existing: PersonIdentity | null; - discardedDamaged: boolean; - }> { - try { - return { existing: await this.store.readStrict(), discardedDamaged: false }; - } catch (error) { - if (error instanceof UnreadableIdentityFileError) { - return { existing: null, discardedDamaged: true }; - } - throw error; - } - } -} diff --git a/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts b/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts deleted file mode 100644 index 10f655a3c..000000000 --- a/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { errorMessage } from "../../../kernel/describe-error.js"; -import type { - TelemetryLocalReadDeclared, - TelemetryLocalReadUnsupported, -} from "../../../kernel/measurement.js"; -import type { Logger } from "../../../kernel/ports/logger.js"; -import type { VersionReader } from "../../../kernel/ports/version-reader.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../kernel/tool.js"; -import { getAiToolConfig, journalHostToAiToolId } from "../../tools/domain/registry.js"; -import type { - PersonIdentity, - PersonIdentityReader, -} from "../domain/ports/person-identity-reader.js"; -import type { RunJournalReader } from "../domain/ports/run-journal-reader.js"; -import type { - LocalCostCandidateRecord, - LocalCostReadResult, - SessionCostReader, -} from "../domain/ports/session-cost-reader.js"; -import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; -import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; -import { resolveSessionProject, type SessionProject } from "../domain/session-project.js"; -import { - attributeMoment, - buildStepIntervals, - type StepInterval, -} from "../domain/step-attribution.js"; -import { SINK_SCHEMA_VERSION, type TelemetrySinkRecord } from "../domain/telemetry-sink-record.js"; -import { - DEFAULT_TELEMETRY_SINK_RETENTION_DAYS, - decideTelemetrySinkRetention, -} from "../domain/telemetry-sink-retention.js"; - -/** Six answers, and only `empty` may ever be printed as a zero — there the zero is the - * measurement. `not-found` is an observation, `not-asked` a decision not to look, `unreadable` a - * failure: collapsing any of them into `empty` is how a session nobody measured reads as free. */ -export type LocalCostToolStatus = - | "found" - | "empty" - | "not-found" - | "unreadable" - | "not-covered" - | "not-asked"; - -export interface LocalCostToolReport { - readonly tool: AiToolId; - readonly status: LocalCostToolStatus; - /** Records the reader returned, before dedup — this is what makes "found" and "empty" - * distinguishable from each other, independent of how many were new. */ - readonly recordsFound: number; - /** Records newly appended to the sink; a re-read of an already-stored session can be - * `status: "found"` with `recordsStored: 0`. */ - readonly recordsStored: number; - /** Why this tool is not covered, or what a covered one's figures cannot yet be used for — both - * from the declaration. On `unreadable`, what the reader itself said. */ - readonly reason?: string; - /** Sessions this tool's reader threw on. Separate from `status` because a sweep can read - * nineteen and fail the twentieth: the figures are real, so the status stays `found`. */ - readonly sessionsFailed: number; - /** What the last failed session's reader said, when any failed. */ - readonly failureReason?: string; -} - -export interface ReadLocalCostOptions { - /** One session by name. Absent reads every session the run journal knows about — the - * only route a person has, since nothing tells them a session identifier. */ - readonly sessionId?: string; - readonly at?: Date; - /** Where to look for `.aidd/config.json` when asking whether the project switch is on. - * Required, not defaulted: this is the one route left that writes the sink. */ - readonly projectRoot: string; - /** Passed through to the same refusal check the switch itself honours - * (`AIDD_TELEMETRY=0`), rather than read from `process.env` here. */ - readonly env: NodeJS.ProcessEnv; -} - -/** What every candidate gets stamped with: facts about where a record came from, never about the - * record itself. `intervals` and `project` are per-session, `person` per-sweep. */ -interface LocalReadAttribution { - readonly intervals: readonly StepInterval[]; - readonly project: SessionProject | null; - readonly person: PersonIdentity | null; -} - -/** What one session's read produced. `sessionId` is on the report because a sweep answers - * about several and a caller has to be able to tell them apart. */ -export interface LocalCostSessionReport { - readonly sessionId: string; - readonly toolReports: readonly LocalCostToolReport[]; -} - -export interface ReadLocalCostResult { - readonly sessions: readonly LocalCostSessionReport[]; - /** Every tool's answer across every session read, so a caller sees one line per tool - * rather than one per tool per session. */ - readonly toolReports: readonly LocalCostToolReport[]; - /** Present only when the sweep refused to run at all. `sessions` and `toolReports` are empty - * then, and an empty sweep must never be told apart from a refusal by inference. */ - readonly refusedReason?: string; -} - -function isPresent(value: string | undefined): value is string { - return value !== undefined; -} - -/** Already-stored records for this session, keyed on `turn_id`; one carrying none is never - * indexed. Mutable on purpose, so two candidates for one turn in a batch match each other. */ -function groupByTurnId( - records: readonly TelemetrySinkRecord[] -): Map { - const groups = new Map(); - for (const record of records) { - if (record.turn_id === undefined) continue; - const bucket = groups.get(record.turn_id); - if (bucket) bucket.push(record); - else groups.set(record.turn_id, [record]); - } - return groups; -} - -function indexStoredRecord( - groups: Map, - record: TelemetrySinkRecord -): void { - if (record.turn_id === undefined) return; - const bucket = groups.get(record.turn_id); - if (bucket) bucket.push(record); - else groups.set(record.turn_id, [record]); -} - -const LOCAL_READ_TURN_COUNTER_KEYS = [ - "input_tokens", - "output_tokens", - "cache_read_tokens", - "cache_creation_tokens", -] as const; - -/** How much of a turn a record accounts for — used only to find the largest of several - * still-open readings of it, never stored, never itself summed into a total. */ -function counterWeight(record: TelemetrySinkRecord): number { - return LOCAL_READ_TURN_COUNTER_KEYS.reduce((sum, key) => sum + (record[key] ?? 0), 0); -} - -/** Whether `candidate` improves on `stored`: every counter at least as large, one strictly - * larger. The sink keeps the larger reading rather than let a figure fall back silently. */ -function strictlyImprovesOn( - stored: TelemetrySinkRecord, - candidate: LocalCostCandidateRecord -): boolean { - let improved = false; - for (const key of LOCAL_READ_TURN_COUNTER_KEYS) { - const before = stored[key]; - const after = candidate[key]; - if (before === undefined) { - if (after !== undefined) improved = true; - continue; - } - if (after === undefined || after < before) return false; - if (after > before) improved = true; - } - return improved; -} - -/** The strongest answer a tool gave anywhere in the sweep. One session read and another failed - * reports `found` — those figures are real — and `sessionsFailed` carries the failure. */ -const STATUS_RANK: readonly LocalCostToolStatus[] = [ - "found", - "unreadable", - "empty", - "not-found", - "not-covered", - // Weakest on purpose: one session where this tool was never asked must never outrank - // another where it actually answered. - "not-asked", -]; - -function strongestOf(tool: AiToolId, reports: readonly LocalCostToolReport[]): LocalCostToolReport { - // The seed for a tool with no report at all: `not-asked` is what "nothing looked at it" means, - // where `not-found` would report an observation never made. - const nothingKnown: LocalCostToolReport = { - tool, - status: "not-asked", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - }; - return reports.reduce( - (strongest, report) => - STATUS_RANK.indexOf(report.status) < STATUS_RANK.indexOf(strongest.status) - ? report - : strongest, - reports[0] ?? nothingKnown - ); -} - -function mergeOneTool( - tool: AiToolId, - sessions: readonly LocalCostSessionReport[] -): LocalCostToolReport { - const reports = sessions.flatMap((session) => - session.toolReports.filter((report) => report.tool === tool) - ); - const failures = reports - .map((report) => report.failureReason) - .filter((reason): reason is string => reason !== undefined); - return { - ...strongestOf(tool, reports), - recordsFound: reports.reduce((sum, report) => sum + report.recordsFound, 0), - recordsStored: reports.reduce((sum, report) => sum + report.recordsStored, 0), - sessionsFailed: failures.length, - ...(failures.length === 0 ? {} : { failureReason: failures[failures.length - 1] }), - }; -} - -/** Nothing here can read this tool at all, with the reason its declaration gives. */ -function notCovered(tool: AiToolId, localRead: TelemetryLocalReadUnsupported): LocalCostToolReport { - return { - tool, - status: "not-covered", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - reason: localRead.reason, - }; -} - -/** This tool's reader was never run, because the journal named another tool. Carries no - * `reason`: nothing is wrong and nothing was measured, so the status is the whole fact. */ -function notAsked(tool: AiToolId): LocalCostToolReport { - return { tool, status: "not-asked", recordsFound: 0, recordsStored: 0, sessionsFailed: 0 }; -} - -/** Its reader failed, so nothing is known about it and something is wrong — distinct from - * `not-found`, where nothing is known and nothing is wrong. */ -function unreadable(tool: AiToolId, failure: string): LocalCostToolReport { - return { - tool, - status: "unreadable", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 1, - reason: failure, - failureReason: failure, - }; -} - -function mergeToolReports( - sessions: readonly LocalCostSessionReport[] -): readonly LocalCostToolReport[] { - return AI_TOOL_IDS.map((tool) => mergeOneTool(tool, sessions)); -} - -const REFUSED_REASON = - "measurement is refused — AIDD_TELEMETRY=0 or the project switch is off; nothing read, " + - "nothing stored"; - -/** Reads what every locally-readable tool's own files hold for one session, normalises it into - * the stored shape, and appends what is not already there. Which tools are readable is each - * tool's own declaration, read through the registry — this class names no tool. */ -export class ReadLocalCostUseCase { - constructor( - private readonly sink: TelemetrySink, - private readonly readers: ReadonlyMap, - private readonly runJournalReader: RunJournalReader, - private readonly personIdentityReader: PersonIdentityReader, - private readonly telemetryEvidenceReader: TelemetryEvidenceReader, - /** The CLI's own version, stamped on every record this sweep stores. Optional only so a - * caller exercising another concern need not invent one - absent, the field is omitted - * from what gets stored, never guessed at. */ - private readonly versionReader?: VersionReader, - /** Only the retention prune below writes here, and only to warn. Optional so a caller that - * does not care about housekeeping warnings need not invent a logger. */ - private readonly logger: Logger = { debug() {}, info() {}, warn() {} }, - private readonly retentionDays: number = DEFAULT_TELEMETRY_SINK_RETENTION_DAYS - ) {} - - async execute(options: ReadLocalCostOptions): Promise { - // Re-checked here, since this is the sink's one remaining writer: a refusal enforced only by - // the hook never writing a journal does not hold against `--session `, which never reads - // one. Before any reader runs, so a refused sweep touches neither the sink nor a tool's files. - if ( - !(await this.telemetryEvidenceReader.isTelemetryEnabled(options.projectRoot, options.env)) - ) { - // Told once, by the caller's own display layer, not here too: `this.logger` exists for - // housekeeping the figures themselves never surface. - return { sessions: [], toolReports: mergeToolReports([]), refusedReason: REFUSED_REASON }; - } - const at = options.at ?? new Date(); - const sessionIds = - options.sessionId === undefined ? await this.journalledSessionIds() : [options.sessionId]; - // Resolved once for the whole sweep, not per session: this is a fact about the machine - // this process is running on, not about any one session it reads. - const person = await this.personIdentityReader.read(); - const sessions: LocalCostSessionReport[] = []; - for (const sessionId of sessionIds) { - sessions.push({ sessionId, toolReports: await this.readOneSession(sessionId, at, person) }); - } - // A sweep prunes; a single named session does not: `report` catches sessions up one at a - // time, and a command asked a question must not delete day files as a side effect. - if (options.sessionId === undefined) await this.pruneOldDayFiles(); - return { sessions, toolReports: mergeToolReports(sessions) }; - } - - /** - * Keeps the sink inside its retention window, once per sweep — the unit a person invokes. - * Every failure warns per file: housekeeping must not cost the figures this sweep just - * stored, and one undeletable file must not spare every older one behind it. - */ - private async pruneOldDayFiles(): Promise { - let prune: readonly string[]; - try { - prune = decideTelemetrySinkRetention( - await this.sink.listDayFiles(), - this.retentionDays - ).prune; - } catch (error) { - this.logger.warn(`telemetry read: retention prune failed - ${errorMessage(error)}`); - return; - } - for (const fileName of prune) { - try { - await this.sink.deleteDayFile(this.sink.rootDir, fileName); - } catch (error) { - this.logger.warn(`telemetry read: could not delete ${fileName} - ${errorMessage(error)}`); - } - } - } - - /** Every session the journal names, oldest file first. A person has no other way to - * learn a session identifier, and the journal has recorded every one of them. */ - private async journalledSessionIds(): Promise { - const journals = await this.runJournalReader.list(); - const ids = journals.map((journal) => journal.session?.vendor_id).filter(isPresent); - return [...new Set(ids)]; - } - - private async readOneSession( - sessionId: string, - at: Date, - person: PersonIdentity | null - ): Promise { - // Read once per session, never per tool: every reader's candidates join the same journal. No - // journal yields empty intervals and a `null` project, so candidates fall through to - // unattributed rather than the read failing, and the project is never re-derived from cwd. - const journal = await this.runJournalReader.read(sessionId); - const attribution: LocalReadAttribution = { - intervals: journal ? buildStepIntervals(journal) : [], - project: resolveSessionProject(journal), - person, - }; - // Only the tool whose session this is: the journal's `session_start` names the host that - // wrote it, so asking the others is useless work one of them pays for in process spawns — the - // OpenCode reader shells out to its binary and waits. Fan out only when the journal names no - // tool, where the tool is genuinely unknown. - const host = journal?.session?.tool; - const namedTool = host === undefined ? null : journalHostToAiToolId(host); - const toolReports: LocalCostToolReport[] = []; - for (const tool of AI_TOOL_IDS) { - toolReports.push(await this.answerFor(tool, namedTool, sessionId, at, attribution)); - } - return toolReports; - } - - /** What this tool has to say about this session. Coverage first, always: "nothing here can read - * this tool" is true of every session, and it carries the declaration's own reason. */ - private async answerFor( - tool: AiToolId, - namedTool: AiToolId | null, - sessionId: string, - at: Date, - attribution: LocalReadAttribution - ): Promise { - const localRead = getAiToolConfig(tool).telemetryLocalRead; - if (localRead.kind !== "declared") return notCovered(tool, localRead); - if (namedTool !== null && tool !== namedTool) return notAsked(tool); - return this.readOneTool(tool, localRead, sessionId, at, attribution); - } - - private async readOneTool( - tool: AiToolId, - localRead: TelemetryLocalReadDeclared, - sessionId: string, - at: Date, - attribution: LocalReadAttribution - ): Promise { - const attempt = await this.attemptRead(tool, sessionId); - if ("failure" in attempt) return unreadable(tool, attempt.failure); - const candidates = attempt.records; - const recordsStored = await this.storeNewCandidates( - tool, - sessionId, - candidates, - at, - attribution - ); - return { - tool, - status: candidates.length > 0 ? "found" : attempt.sessionFound ? "empty" : "not-found", - recordsFound: candidates.length, - recordsStored, - sessionsFailed: 0, - ...(localRead.limitation !== undefined ? { reason: localRead.limitation } : {}), - }; - } - - /** The one place this use case catches: a fan-out over independent sources, where one reader - * failing must not cost every other tool's figures, nor every other session's. */ - private async attemptRead( - tool: AiToolId, - sessionId: string - ): Promise { - const reader = this.readers.get(tool); - if (!reader) return { records: [], sessionFound: false }; - try { - return await reader.read(sessionId); - } catch (error) { - return { failure: error instanceof Error ? error.message : String(error) }; - } - } - - /** Matches on `turn_id` alone, never a hash of the line: the tool's own file keeps growing as - * the same record is read again. A stored turn is dropped unless a `request` local-read record - * strictly improves on it, which appends a second line for `collapseSupersededTurns` to - * reconcile. A correction is a larger counter, never a field the stored record lacks. */ - private async storeNewCandidates( - tool: AiToolId, - sessionId: string, - candidates: readonly LocalCostCandidateRecord[], - at: Date, - attribution: LocalReadAttribution - ): Promise { - if (candidates.length === 0) return 0; - const byTurnId = groupByTurnId(await this.sink.readRecordsForVendor(sessionId)); - let stored = 0; - for (const candidate of candidates) { - const prior = candidate.turn_id === undefined ? undefined : byTurnId.get(candidate.turn_id); - if (prior && !this.isLocalReadTurnCorrection(candidate, prior)) continue; - const record = this.stampProvenanceAndTool(tool, candidate, attribution); - await this.sink.appendRecord(record, at); - indexStoredRecord(byTurnId, record); - stored++; - } - return stored; - } - - /** Never for a `kind: "session"` record: Copilot's shutdown total shares this match but is a - * one-shot cumulative figure, which a re-read would start doubling. Otherwise only a strict - * improvement on the largest stored, never gated on `turn_end` — a larger candidate is itself - * proof the stored reading was not final. */ - private isLocalReadTurnCorrection( - candidate: LocalCostCandidateRecord, - prior: readonly TelemetrySinkRecord[] - ): boolean { - if (candidate.kind !== "request") return false; - const priorReads = prior.filter((r) => r.kind === "request" && r.provenance === "local-read"); - if (priorReads.length === 0) return false; - const largest = priorReads.reduce((best, r) => - counterWeight(r) > counterWeight(best) ? r : best - ); - return strictlyImprovesOn(largest, candidate); - } - - // The caller asked this tool's reader by name — that is the fact this stamps, never - // inferred from the candidate itself, which the reader's contract forbids it naming. - private stampProvenanceAndTool( - tool: AiToolId, - candidate: LocalCostCandidateRecord, - { intervals, project, person }: LocalReadAttribution - ): TelemetrySinkRecord { - return { - ...candidate, - sink_schema_version: SINK_SCHEMA_VERSION, - provenance: "local-read", - tool, - ...this.resolveStepAttribution(candidate, intervals), - ...(project === null - ? {} - : { project_id: project.projectId, project_field: project.projectField }), - ...(person === null ? {} : { person_id: person.personId }), - ...(person?.displayName === undefined ? {} : { person_display_name: person.displayName }), - ...(this.versionReader === undefined ? {} : { cli_version: this.versionReader.get() }), - }; - } - - // Where the candidate carries `step`, the tool stated it directly — exact, never second-guessed - // by an interval, which is only an inference. A candidate with no moment, or one earlier than - // every interval, comes back unattributed rather than folded into the nearest step. - private resolveStepAttribution( - candidate: LocalCostCandidateRecord, - intervals: readonly StepInterval[] - ): Pick { - if (candidate.step !== undefined) { - return { - step_attribution: "tool-stated", - step: candidate.step, - step_plugin: candidate.step_plugin, - }; - } - const attribution = attributeMoment(intervals, candidate.event_timestamp); - return { step_attribution: attribution.source, step: attribution.step, step_plugin: undefined }; - } -} diff --git a/cli/src/contexts/telemetry/application/report-cost-use-case.ts b/cli/src/contexts/telemetry/application/report-cost-use-case.ts deleted file mode 100644 index f21caef86..000000000 --- a/cli/src/contexts/telemetry/application/report-cost-use-case.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { UnreadableIdentityFileError } from "../../../kernel/errors.js"; -import type { Logger } from "../../../kernel/ports/logger.js"; -import { AI_TOOL_IDS } from "../../../kernel/tool.js"; -import { getAiToolConfig } from "../../tools/domain/registry.js"; -import { - buildCostReport, - type CostReport, - type CostReportFilters, - type CostReportInput, - type CostReportSessionJournal, - type CostReportToolCapability, - type CostReportToolDeclaration, - type PersonIdentityUnusableCause, -} from "../domain/cost-report.js"; -import { buildFlowIntervals } from "../domain/flow-attribution.js"; -import type { PersonIdentity } from "../domain/ports/person-identity-reader.js"; -import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; -import type { RunJournal, RunJournalReader } from "../domain/ports/run-journal-reader.js"; -import type { TaskBacklogReader } from "../domain/ports/task-backlog-reader.js"; -import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; -import type { TelemetrySink, TelemetrySinkPeriodRead } from "../domain/ports/telemetry-sink.js"; -import type { ResolvedReportPeriod } from "../domain/report-period.js"; -import { - attributeMoment, - buildStepIntervals, - type StepInterval, -} from "../domain/step-attribution.js"; -import { buildTaskIntervals } from "../domain/task-attribution.js"; -import { - type TaskBacklogDeclaration, - taskFolderPathFromIdentity, -} from "../domain/task-backlog-link.js"; -import { type TaskIdentity, taskIdentityFromWrittenPath } from "../domain/task-identity.js"; -import type { TelemetrySinkRecord } from "../domain/telemetry-sink-record.js"; -import type { ReadLocalCostResult, ReadLocalCostUseCase } from "./read-local-cost-use-case.js"; - -export interface ReportCostOptions { - /** Already two absolute days, resolved once at the edge — so nothing from here down reads a - * clock, and the same options answer the same twice. */ - readonly period: ResolvedReportPeriod; - /** Restrict to the sessions that wrote into this task. Absent reports the whole period. */ - readonly task?: TaskIdentity; - /** Any of `project`, `step`, `model` and `tool` - each optional, composing with `task` - * and each other by `and`. */ - readonly filters?: CostReportFilters; - /** Where to look for `.aidd/config.json` when asking whether the project switch is on. */ - readonly projectRoot: string; - /** Passed through to the same refusal check the switch itself honours (`AIDD_TELEMETRY=0`), - * rather than read from `process.env` down in an adapter. */ - readonly env: NodeJS.ProcessEnv; -} - -/** What each tool declares about being read at all, as data the pure report consumes — so a - * report prints the declaration's own reason rather than a zero, `limitation` included. */ -function declaredTools(): readonly CostReportToolDeclaration[] { - return AI_TOOL_IDS.map((tool) => { - const config = getAiToolConfig(tool); - const localRead = config.telemetryLocalRead; - const capability: CostReportToolCapability = { - localRead: localRead.kind === "declared" ? localRead.supplies : null, - // No tool declares an export route any more, so nothing could ever supply this. Kept as - // `null` rather than a type change rippling through the `--json` contract. - export: null, - journalAttributable: config.telemetryJournalHost !== undefined, - taskAttributable: config.telemetryTaskAttributable, - }; - if (localRead.kind === "declared") { - return { - tool, - coverage: "covered" as const, - ...(localRead.limitation === undefined ? {} : { reason: localRead.limitation }), - capability, - }; - } - return { - tool, - coverage: "not-covered" as const, - ...(localRead.kind === "unsupported" ? { reason: localRead.reason } : {}), - capability, - }; - }); -} - -/** The first and last moment a journal's own lines carry — every line kind, since the question is - * "was this journal open then". Not capped at the period's end: the sink returns no record past - * it, so a clock-skewed line can widen the span but never pull a record in. */ -const LAST_MILLISECOND_OF_A_SECOND = 999; - -function witnessedSpan(journal: RunJournal): { fromMs: number; toMs: number } | undefined { - const moments = [ - ...journal.boundaries, - ...journal.taskDeclarations, - ...journal.filesWritten, - ...(journal.session ? [journal.session] : []), - ] - .map((line) => Date.parse(line.at)) - .filter((atMs) => !Number.isNaN(atMs)); - if (moments.length === 0) return undefined; - // The end is the end of the second the last line names: the writing hook strips milliseconds - // from a journal moment while a record carries them, so comparing the two as instants would - // refuse a record that landed inside the very second the journal last wrote. - return { - fromMs: Math.min(...moments), - toMs: Math.max(...moments) + LAST_MILLISECOND_OF_A_SECOND, - }; -} - -function toSessionJournal( - journal: RunJournal, - periodEndMs: number -): CostReportSessionJournal | null { - if (!journal.session) return null; - const span = witnessedSpan(journal); - return { - vendorId: journal.session.vendor_id, - tool: journal.session.tool, - ...(journal.session.project_id === undefined ? {} : { projectId: journal.session.project_id }), - writtenPaths: journal.filesWritten.map((written) => written.path), - taskIntervals: buildTaskIntervals(journal, periodEndMs), - flowIntervals: buildFlowIntervals(journal, periodEndMs), - ...(span === undefined ? {} : { witnessed: span }), - }; -} - -/** Every distinct task identity this period's journals could ever key `by_task` on. Each is - * resolved to its folder's declaration exactly once, never once per record. */ -function distinctTaskIdentities( - journals: readonly RunJournal[], - periodEndMs: number -): readonly TaskIdentity[] { - const seen = new Set(); - const identities: TaskIdentity[] = []; - const remember = (identity: TaskIdentity | null): void => { - if (identity === null || seen.has(identity)) return; - seen.add(identity); - identities.push(identity); - }; - for (const journal of journals) { - for (const interval of buildTaskIntervals(journal, periodEndMs)) { - remember(taskIdentityFromWrittenPath(interval.path)); - } - // Written paths too, not declared intervals alone: that folder can declare a backlog item, - // and declarations alone would claim it declares none, from a lookup that never ran. - for (const written of journal.filesWritten) { - remember(taskIdentityFromWrittenPath(written.path)); - } - } - return identities; -} - -/** One read per distinct task identity, through the port, never re-read per record. A reader - * that throws is not this function's to catch: `TaskBacklogReader.read` promises it never does. */ -async function taskBacklogDeclarationsOf( - reader: TaskBacklogReader, - journals: readonly RunJournal[], - periodEndMs: number -): Promise> { - const declarations = new Map(); - for (const identity of distinctTaskIdentities(journals, periodEndMs)) { - declarations.set(identity, await reader.read(taskFolderPathFromIdentity(identity))); - } - return declarations; -} - -const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - -/** The first moment no record in the period can reach: `toDay` itself runs through 23:59:59.999 - * UTC, so this is the *start* of the day after, never `toDay`'s own start — which would cut off - * a record legitimately timestamped later that day. */ -function periodEndMsOf(toDay: string): number { - return Date.parse(`${toDay}T00:00:00Z`) + MILLISECONDS_PER_DAY; -} - -interface PersonIdentityFields { - readonly identity: PersonIdentity | null; - readonly identityUnusableCause?: PersonIdentityUnusableCause; -} - -/** - * Never aborts the report over a damaged identity file: that is one dependency's own trouble, - * and the figures must still come back whole. Names which of the two causes fired rather than - * folding both into a boolean, and re-throws anything else rather than mislabel an unexpected - * failure as a familiar-looking caveat. - */ -async function personIdentityFields(store: PersonIdentityStore): Promise { - try { - const identity = await store.readStrict(); - return identity === null ? { identity: null, identityUnusableCause: "absent" } : { identity }; - } catch (error) { - if (error instanceof UnreadableIdentityFileError) { - return { identity: null, identityUnusableCause: "unreadable" }; - } - throw error; - } -} - -/** `identity` and `identityUnusableCause` together, as `buildCostReport` wants them - pulled out - * so `execute` reads as one shape assembled from its own reads. */ -function identityInputFields( - fields: PersonIdentityFields -): Pick { - return { - identity: fields.identity, - ...(fields.identityUnusableCause === undefined - ? {} - : { identityUnusableCause: fields.identityUnusableCause }), - }; -} - -/** Which skill each prompt opened, from the journal's own `step_start` lines. First wins: - * several steps can open under one prompt, and a prompt names the step its work *began* in — - * taking the last would answer for the reasoning that produced an earlier step's output. */ -function promptToSkill(journal: RunJournal): ReadonlyMap { - const byPrompt = new Map(); - for (const boundary of journal.boundaries) { - if (boundary.type !== "step_start" || boundary.turn_id === undefined) continue; - if (!byPrompt.has(boundary.turn_id)) byPrompt.set(boundary.turn_id, boundary.skill); - } - return byPrompt; -} - -/** The step a record's own prompt opened. Outranks the interval, and says so: `prompt-matched` - * is an identifier two sources agree on, where `journal-interval` infers from moments, so it - * stays true when two tasks advance at once. Two tasks inside *one* prompt stay indivisible: a - * billed amount cannot be split without inventing a ratio. */ -function matchOnPrompt( - record: TelemetrySinkRecord, - byPrompt: ReadonlyMap | undefined -): { readonly source: "prompt-matched"; readonly step: string } | null { - const step = journalNamedStep(record, byPrompt) ?? record.prompt_skill; - return step === undefined ? null : { source: "prompt-matched", step }; -} - -/** What the run journal says the record's own prompt opened, asked first: the journal was - * written by a hook the host itself fired, while `prompt_skill` is read back off a transcript - * afterwards, so the reading with a witness wins. A session the journal never saw at all falls - * through to the record's own. */ -function journalNamedStep( - record: TelemetrySinkRecord, - byPrompt: ReadonlyMap | undefined -): string | undefined { - if (record.prompt_id === undefined || byPrompt === undefined) return undefined; - return byPrompt.get(record.prompt_id); -} - -/** Every record's step, derived rather than trusted from disk: `step_attribution` is stored when - * the record is read, frozen at whatever the rule answered then. `tool-stated` is left alone, - * being witnessed rather than inferred, and so is a session this period's journals say nothing - * about — no interval to judge it against, and a blanker answer is worse than a stale one. */ -function withDerivedStep( - records: readonly TelemetrySinkRecord[], - journals: readonly RunJournal[] -): readonly TelemetrySinkRecord[] { - const bySession = new Map(); - const skillByPrompt = new Map>(); - for (const journal of journals) { - if (!journal.session) continue; - bySession.set(journal.session.vendor_id, buildStepIntervals(journal)); - skillByPrompt.set(journal.session.vendor_id, promptToSkill(journal)); - } - - return records.map((record) => { - if (record.step_attribution === "tool-stated") return record; - const intervals = bySession.get(record.vendor_id); - if (intervals === undefined) return record; - - const matched = matchOnPrompt(record, skillByPrompt.get(record.vendor_id)); - const derived = matched ?? attributeMoment(intervals, record.event_timestamp); - // Rebuilt rather than spread over: a record that carried a step from an earlier reading - // must lose it when the journal no longer names one, and a spread would keep it. - const { step: _step, step_plugin: _plugin, ...rest } = record; - return { - ...rest, - step_attribution: derived.source, - ...(derived.step === undefined ? {} : { step: derived.step }), - }; - }); -} - -/** Every gathered read, folded into the one shape `buildCostReport` wants - kept on its own - * so `execute` reads as "gather, then assemble," not a wall of field assignments. */ -function toReportInput( - options: ReportCostOptions, - read: Awaited>, - journals: readonly RunJournal[], - identity: PersonIdentityFields, - measurementEnabled: boolean, - taskBacklogDeclarations: ReadonlyMap -): CostReportInput { - const { fromDay, toDay } = options.period; - const periodEndMs = periodEndMsOf(toDay); - return { - fromDay, - toDay, - records: withDerivedStep(read.records, journals), - journals: journals - .map((journal) => toSessionJournal(journal, periodEndMs)) - .filter((journal) => journal !== null), - declaredTools: declaredTools(), - undatedRecords: read.undated.length, - unreadableLines: read.skippedLines, - ...(options.task === undefined ? {} : { task: options.task }), - ...(options.filters === undefined ? {} : { filters: options.filters }), - knownValues: read.knownValues, - measurementEnabled, - taskBacklogDeclarations, - ...identityInputFields(identity), - }; -} - -/** Sessions holding at least one stored record a re-read could never be matched against: a - * re-read reconciles on `turn_id`, and a record carrying none is never indexed, so re-reading - * such a session would append its records a second time. A host that writes no such identifier - * must not be silently doubled. */ -function sessionsWithAnUnmatchableRecord( - stored: readonly TelemetrySinkRecord[] -): ReadonlySet { - const sessions = new Set(); - for (const record of stored) { - if (record.turn_id === undefined) sessions.add(record.vendor_id); - } - return sessions; -} - -/** Every session the journal names whose own `session_start` falls inside the period — never - * "the ones the sink has never seen", which freezes a session still running the moment its first - * turn is stored. Re-reading is safe, the local read dedupes per `turn_id`; the period bound is - * what stops a one-week report re-reading every session ever journalled. */ -function sessionsToCatchUp( - stored: readonly TelemetrySinkRecord[], - journals: readonly RunJournal[], - fromMs: number, - periodEndMs: number -): readonly string[] { - const unmatchable = sessionsWithAnUnmatchableRecord(stored); - const missing: string[] = []; - for (const journal of journals) { - const session = journal.session; - if (session === undefined || unmatchable.has(session.vendor_id)) continue; - const atMs = Date.parse(session.at); - // `periodEndMs` is the first instant *after* the period, which is why this is `>=` and not - // `>`. Taken from `periodEndMsOf`, never recomputed here: an end computed from `toDay`'s own - // start excludes the whole of `toDay`, which `--days N` always makes today. - if (Number.isNaN(atMs) || atMs < fromMs || atMs >= periodEndMs) continue; - missing.push(session.vendor_id); - } - return missing; -} - -/** - * Answers what a period, or one task inside it, cost. Orchestration only: the rules belong to - * `domain/cost-report.ts`. It names no tool and computes no figure - in particular no amount, - * since the rates live outside this repository and an amount is only ever reported where a - * tool's own files already carried one. - */ -export class ReportCostUseCase { - constructor( - private readonly sink: TelemetrySink, - private readonly runJournalReader: RunJournalReader, - private readonly personIdentityStore: PersonIdentityStore, - private readonly telemetryEvidenceReader: TelemetryEvidenceReader, - private readonly taskBacklogReader: TaskBacklogReader, - /** Where `warnAboutFailures` says what a reader could not answer. */ - private readonly logger: Logger, - /** Reads the sessions the sink has not caught up with yet, before the report is built. - * Optional so a caller exercising the report's own rules need not wire it; absent, this - * reports exactly what the sink already holds. */ - private readonly readLocalCost?: ReadLocalCostUseCase - ) {} - - /** Whether the project switch is on right now - independent of the sink and the journal, - * so gathered on its own rather than folded into either of their reads. */ - private async measurementEnabled(options: ReportCostOptions): Promise { - return this.telemetryEvidenceReader.isTelemetryEnabled(options.projectRoot, options.env); - } - - async execute(options: ReportCostOptions): Promise { - const { fromDay, toDay } = options.period; - const from = new Date(`${fromDay}T00:00:00Z`); - const to = new Date(`${toDay}T00:00:00Z`); - const periodEndMs = periodEndMsOf(toDay); - // Every journal, not only the period's: a journal carries no date in its file name, and the - // records it is joined to were already selected by their own moments. - const journals = await this.runJournalReader.list(); - const read = await this.catchUp( - await this.sink.readRecordsInPeriod(from, to), - journals, - options, - { from, to, periodEndMs } - ); - const identity = await personIdentityFields(this.personIdentityStore); - const measurementEnabled = await this.measurementEnabled(options); - const taskBacklogDeclarations = await taskBacklogDeclarationsOf( - this.taskBacklogReader, - journals, - periodEndMsOf(toDay) - ); - - return buildCostReport( - toReportInput(options, read, journals, identity, measurementEnabled, taskBacklogDeclarations) - ); - } - - /** Reads whatever the sink has not caught up with, then asks it again. `ReadLocalCostUseCase` - * refuses on its own when measurement is off, so this needs no second gate: a refusal stores - * nothing and the report describes what was already there. */ - private async catchUp( - read: TelemetrySinkPeriodRead, - journals: readonly RunJournal[], - options: ReportCostOptions, - period: { from: Date; to: Date; periodEndMs: number } - ): Promise { - if (this.readLocalCost === undefined) return read; - const missing = sessionsToCatchUp( - read.records, - journals, - period.from.getTime(), - period.periodEndMs - ); - if (missing.length === 0) return read; - for (const sessionId of missing) { - this.warnAboutFailures( - sessionId, - await this.readLocalCost.execute({ - sessionId, - projectRoot: options.projectRoot, - env: options.env, - }) - ); - } - return this.sink.readRecordsInPeriod(period.from, period.to); - } - - /** Says what a reader could not answer, since behind a report nobody sees the read's own - * output: a period where every reader threw would otherwise print what a period with no spend - * prints. Warnings go to stderr, so a `--json` caller's stdout stays one parseable object. */ - private warnAboutFailures(sessionId: string, result: ReadLocalCostResult): void { - const say = this.logger.warn.bind(this.logger); - for (const report of result.toolReports) { - if (report.status !== "unreadable") continue; - say( - `telemetry report: ${report.tool} could not be read for session ${sessionId}` + - `${report.failureReason === undefined ? "" : ` - ${report.failureReason}`}` - ); - } - } -} diff --git a/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts deleted file mode 100644 index 8e119a314..000000000 --- a/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { FileReader } from "../../../kernel/ports/file-reader.js"; -import type { FileWriter } from "../../../kernel/ports/file-writer.js"; -import type { Logger } from "../../../kernel/ports/logger.js"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerManagerSnippet, -} from "../domain/formats/commit-session-trailer.js"; -import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; -import type { VersionControl } from "../domain/ports/version-control.js"; -import { - buildTelemetrySwitchFile, - parseTelemetrySwitchFile, - telemetryConfigPath, -} from "../domain/telemetry-switch.js"; - -export interface TelemetryOffOptions { - readonly projectRoot: string; -} - -export interface TelemetryOffResult { - readonly switchPath: string; - readonly switchChanged: boolean; -} - -/** Sets the switch off, preserving any `endpoint` the file already carries. Never edits a tool's - * own settings file: nothing here wrote one, so editing it could erase somebody's real setup. */ -export class TelemetryOffUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly logger: Logger, - private readonly telemetryEvidenceReader: TelemetryEvidenceReader, - private readonly git: VersionControl - ) {} - - async execute(options: TelemetryOffOptions): Promise { - const switchPath = telemetryConfigPath(options.projectRoot); - this.logger.info(`AIDD telemetry switch -> ${switchPath}`); - const switchChanged = await this.turnSwitchOff(switchPath); - await this.stopTrailingCommits(options.projectRoot); - await this.warnLeftoverExportConfig(options.projectRoot); - return { switchPath, switchChanged }; - } - - /** `on` wrote the hook line and the delegate, so `off` takes both back — whatever the switch's - * previous state was: already off with the hook installed is what a second `off` must fix. */ - private async stopTrailingCommits(projectRoot: string): Promise { - const result = await this.git.removeCommitMessageDelegate( - projectRoot, - SESSION_TRAILER_DELEGATE_FILE - ); - if (result.removed) { - this.logger.info( - `New commits will carry no ${SESSION_TRAILER_TOKEN} trailer. Commits already made ` + - "keep theirs — nothing here rewrites history." - ); - } - // A manager's own config is committed, shared config this CLI never writes, so the job - // calling the removed delegate stays — its own `[ -f ]` guard now finds nothing to run. - if (result.hookManager !== undefined && result.managerCallsDelegate === true) { - const { targetFile } = sessionTrailerManagerSnippet( - result.hookManager, - SESSION_TRAILER_DELEGATE_FILE - ); - this.logger.info( - `${targetFile} still calls the delegate this just removed — that file is not this ` + - "CLI's to edit, so the job is left in place. Its own `[ -f ]` guard now finds " + - "nothing there, so it runs nothing; delete it from " + - `${targetFile} by hand if you want it gone too.` - ); - } - } - - /** Names what `off` cannot touch: a tool's own settings file still carrying an export key. - * Silence is the failure this closes — nothing else tells a person their machine exports. */ - private async warnLeftoverExportConfig(projectRoot: string): Promise { - const leftovers = await this.telemetryEvidenceReader.findLeftoverExportConfig(projectRoot); - for (const leftover of leftovers) { - this.logger.warn( - `${leftover.path} still sets ${leftover.keys.join(", ")} — this switch cannot ` + - "touch a tool's own settings file. Delete these keys from its `env` block by " + - "hand to stop that export." - ); - } - } - - private async turnSwitchOff(switchPath: string): Promise { - if (!(await this.fs.fileExists(switchPath))) { - this.logger.info("AIDD telemetry: already off, unchanged."); - return false; - } - const raw = await this.fs.readFile(switchPath); - const current = parseTelemetrySwitchFile(raw); - if (current?.enabled !== true) { - this.logger.info("AIDD telemetry: already off, unchanged."); - return false; - } - const next = buildTelemetrySwitchFile(raw, { enabled: false, endpoint: current.endpoint }); - await this.fs.writeFile(switchPath, next); - this.logger.info("AIDD telemetry: off."); - return true; - } -} diff --git a/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts deleted file mode 100644 index 36c53e10a..000000000 --- a/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { TelemetryProjectScopeRequiresYesError } from "../../../kernel/errors.js"; -import { RUNS_ENTRY } from "../../../kernel/paths.js"; -import type { FileReader } from "../../../kernel/ports/file-reader.js"; -import type { FileWriter } from "../../../kernel/ports/file-writer.js"; -import type { Logger } from "../../../kernel/ports/logger.js"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerDelegateScript, - sessionTrailerManagerInstruction, - sessionTrailerManagerSnippet, -} from "../domain/formats/commit-session-trailer.js"; -import type { IgnoreEntries } from "../domain/ports/ignore-entries.js"; -import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; -import type { VersionControl } from "../domain/ports/version-control.js"; -import type { HookManager } from "../domain/telemetry-setup.js"; -import { - buildTelemetrySwitchFile, - parseTelemetrySwitchFile, - type TelemetrySwitch, - telemetryConfigPath, -} from "../domain/telemetry-switch.js"; - -export interface TelemetryOnOptions { - readonly projectRoot: string; - /** `.aidd/config.json` is git-tracked, so a fresh clone inherits the project's decision — - * which is why anyone is asked at all. */ - readonly confirmed: boolean; -} - -export interface TelemetryOnResult { - readonly switchPath: string; - readonly switchChanged: boolean; -} - -/** Owns the AIDD telemetry switch alone: flips `.aidd/config.json`'s `telemetry.enabled` and - * git-ignores the run journal. Never touches a tool's own settings file — arming a tool to - * export and recording locally are two different promises. Any `endpoint` there is preserved. */ -export class TelemetryOnUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly logger: Logger, - private readonly gitignore: IgnoreEntries, - private readonly git: VersionControl, - private readonly sink: TelemetrySink - ) {} - - async execute(options: TelemetryOnOptions): Promise { - const switchPath = telemetryConfigPath(options.projectRoot); - this.guardConfirmed(options); - // Before the switch, not after: `appendRecord` creates the directory itself, so without this - // the first failure lands at the first record, on whoever reads rather than whoever turned on. - await this.sink.ensureWritable(); - this.logger.info(`AIDD telemetry switch -> ${switchPath}`); - // The switch is written last, once everything it promises is in place: a failure partway - // through must never leave `enabled: true` describing a setup that stopped short of it. - await this.protectRunsDir(options.projectRoot); - await this.makeCommitsJoinable(options.projectRoot); - const switchChanged = await this.writeSwitch(switchPath); - return { switchPath, switchChanged }; - } - - // `.aidd/config.json` is git-tracked, the consequence `endpoint --scope project` already - // refuses without `--yes`. Fires unconditionally, whatever the switch's current state. - private guardConfirmed(options: TelemetryOnOptions): void { - if (options.confirmed) return; - throw new TelemetryProjectScopeRequiresYesError( - "aidd telemetry on", - telemetryConfigPath(options.projectRoot) - ); - } - - // Re-checked on every successful `on`, switch newly written or not: a project turned on before - // this existed still gets the journal ignored, without turning telemetry off and on again. - private async protectRunsDir(projectRoot: string): Promise { - const added = await this.gitignore.execute(projectRoot, [RUNS_ENTRY]); - if (added) { - this.logger.info( - `Added ${RUNS_ENTRY} to .gitignore — the journal names no person, only the ` + - "repository, the task folders written into, the skills run, and their timings. " + - "Delete that line to commit it instead." - ); - } - const tracked = await this.git.listTrackedFiles(projectRoot, RUNS_ENTRY); - if (tracked.length === 0) return; - this.logger.warn( - "Already tracked by git — the repository, the task folders written into, the skills " + - `run, and their timings:\n${tracked.map((file) => ` ${file}`).join("\n")}\n` + - "Nothing removed or rewritten — your call." - ); - } - - /** Re-run on every successful `on`, switch newly written or not, for the same reason - * `protectRunsDir` is. Announced rather than done quietly: it writes into commit messages a - * team will read, so the sentence saying so and the command undoing it must be findable. */ - private async makeCommitsJoinable(projectRoot: string): Promise { - const install = await this.git.installCommitMessageDelegate( - projectRoot, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - if (install.hookManager !== undefined) { - this.reportManagedHook(install.hookManager, install.managerCallsDelegate === true); - return; - } - if (!install.lineAdded) return; - this.logger.info( - `Commits made by an AI session will carry an ${SESSION_TRAILER_TOKEN} trailer, so what ` + - "a session cost can be read per commit. A commit no session made carries nothing. " + - "`aidd telemetry off` removes it." - ); - } - - /** `prepare-commit-msg` under a manager is committed, shared config this CLI may not append - * to — an append would reach every clone through a commit nobody reviewed — and lefthook - * regenerates it from that config anyway. A chain already wired prints nothing: the ordinary - * trailer promise above already covers it. */ - private reportManagedHook(manager: HookManager, wired: boolean): void { - if (wired) return; - const { targetFile, snippet } = sessionTrailerManagerSnippet( - manager, - SESSION_TRAILER_DELEGATE_FILE - ); - this.logger.info( - `${manager} owns prepare-commit-msg here, so nothing was appended to it. Commits will ` + - `not carry an ${SESSION_TRAILER_TOKEN} trailer until you ` + - `${sessionTrailerManagerInstruction(manager, targetFile)}:\n\n${snippet}\n` - ); - } - - private async readIfExists(path: string): Promise { - return (await this.fs.fileExists(path)) ? await this.fs.readFile(path) : null; - } - - private async writeSwitch(switchPath: string): Promise { - const existingRaw = await this.readIfExists(switchPath); - const existing: TelemetrySwitch | null = - existingRaw !== null ? parseTelemetrySwitchFile(existingRaw) : null; - if (existing?.enabled === true) { - this.logger.info("AIDD telemetry: already on, unchanged."); - return false; - } - const next = buildTelemetrySwitchFile(existingRaw, { - enabled: true, - endpoint: existing?.endpoint, - }); - await this.fs.writeFile(switchPath, next); - this.logger.info("AIDD telemetry: on."); - return true; - } -} diff --git a/cli/src/contexts/telemetry/domain/cost-report-envelope.ts b/cli/src/contexts/telemetry/domain/cost-report-envelope.ts deleted file mode 100644 index 4e26961da..000000000 --- a/cli/src/contexts/telemetry/domain/cost-report-envelope.ts +++ /dev/null @@ -1,424 +0,0 @@ -import type { TelemetryRouteSupply } from "../../../kernel/measurement.js"; -import type { AiToolId } from "../../../kernel/tool.js"; -import type { - AgentAttributionSource, - CostReport, - CostReportEmptySelection, - CostReportFilters, - CostReportToolCoverage, - CostTotals, - PersonIdentityUnusableCause, -} from "./cost-report.js"; -import type { FlowAttributionSource } from "./flow-attribution.js"; -import type { PersonResolution } from "./person-resolution.js"; -import type { StepAttributionSource } from "./step-attribution.js"; -import type { TaskAttributionSource, TaskUnattributedReason } from "./task-attribution.js"; - -/** Bumped when a consumer that understood the previous shape would misread this one, so it - * can refuse rather than guess - the same reason `sink_schema_version` exists on a stored - * line. Adding a field a consumer may ignore is not a bump; changing what an existing field - * means, or adding a value to a union it switches on, is. Each bump's reason is in - * `aidd_docs/product/cost-report-contract.md`. */ -export const COST_REPORT_ENVELOPE_VERSION = 15; - -/** Money as whole micro-dollars, so a consumer summing several reports gets the same answer - * this one did. Divide by 1,000,000 for dollars, and only at the moment of display. */ -export interface CostReportEnvelopeTotals { - readonly requests: number; - readonly cost_micro_usd?: number; - readonly input_tokens?: number; - readonly output_tokens?: number; - readonly cache_read_tokens?: number; - readonly cache_creation_tokens?: number; -} - -export interface CostReportEnvelopeStepRow { - readonly step?: string; - readonly attribution: StepAttributionSource; - readonly totals: CostReportEnvelopeTotals; -} - -/** Largest first, plus one row for what named none - `model` absent there. */ -export interface CostReportEnvelopeModelRow { - readonly model?: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** What a route was measured to supply. `null` where the tool declares no such route at all - * - a different fact from a declared route that supplies nothing. */ -export interface CostReportEnvelopeRouteSupply { - readonly token_counters: boolean; - readonly amount: boolean; - readonly tool_stated_step: boolean; - /** The route names the agent a record belongs to, and so also says when one is the main - * thread's own. Without it `by_agent` reads this tool's records as stating no agent. */ - readonly agent_name: boolean; -} - -export interface CostReportEnvelopeCapability { - readonly local_read: CostReportEnvelopeRouteSupply | null; - readonly export: CostReportEnvelopeRouteSupply | null; - /** False means the run journal never names this tool's sessions: no step can be derived - * from an interval, and a journal sweep never reaches one of them. A consumer seeing a - * readable tool with no figures looks here before concluding it did no work. */ - readonly journal_attributable: boolean; - readonly task_attributable: boolean; -} - -export interface CostReportEnvelopeToolRow { - readonly tool: AiToolId; - readonly coverage: CostReportToolCoverage; - readonly reason?: string; - /** Read this rather than inferring from whether a figure is present. A tool that cannot - * supply an amount and a session that cost nothing look identical in the numbers. */ - readonly capability: CostReportEnvelopeCapability; - readonly totals: CostReportEnvelopeTotals; - /** A local-read `kind: "session"` total, present only for a tool whose own file yields one - * already-complete session figure rather than per-request records - today, only Copilot. - * Never folded into `totals`, which counts billed requests alone. */ - readonly session_totals?: CostReportEnvelopeTotals; -} - -export interface CostReportEnvelopeAttributionRow { - readonly attribution: StepAttributionSource; - readonly totals: CostReportEnvelopeTotals; -} - -/** The same idea, one axis over: how much of a `--task` report's total came from a - * declared interval versus a written file. */ -export interface CostReportEnvelopeTaskAttributionRow { - readonly attribution: TaskAttributionSource; - readonly totals: CostReportEnvelopeTotals; -} - -/** Largest first, plus one row for what named none - `project` absent there. */ -export interface CostReportEnvelopeProjectRow { - readonly project?: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** `attribution` is present only alongside `task` and says which route named it: - * `"declared"` where a `task_declared` interval covers the record, `"inferred"` where the - * session wrote into exactly one task folder, so one task can carry two rows. A row for what - * fell in no declared interval carries `reason` instead - never both, never neither. */ -export interface CostReportEnvelopeTaskRow { - readonly task?: string; - readonly attribution?: TaskAttributionSource; - readonly reason?: TaskUnattributedReason; - readonly totals: CostReportEnvelopeTotals; -} - -/** On a row with no `backlog`, `declaration` and `reason` are never both present and never - * neither. */ -export interface CostReportEnvelopeBacklogRow { - readonly backlog?: string; - readonly declaration?: "none" | "unreadable"; - readonly reason?: TaskUnattributedReason; - readonly totals: CostReportEnvelopeTotals; -} - -/** `agent` is present exactly when `attribution` is `tool-stated`. The two rows naming none - * are different facts, never merged: `main-thread` is a tool that names agents saying this - * record belongs to none, `not-stated` a tool whose route never names one. */ -export interface CostReportEnvelopeAgentRow { - readonly agent?: string; - readonly attribution: AgentAttributionSource; - readonly totals: CostReportEnvelopeTotals; -} - -/** `started_at` is the earliest moment in the prompt, and only a named prompt carries one: - * the unnamed row is drawn from many turns, so a start moment would assert a unit that never - * existed. */ -export interface CostReportEnvelopePromptRow { - readonly prompt?: string; - readonly started_at?: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** `attribution` says how this flow came to be known: `journal-interval` for one the journal - * opened and closed, `tool-stated` for one only a record's own tool named, `unattributed` - * for work that joined neither. A `tool-stated` row carries no `started_at` - it is a bucket - * drawn from however many runs of that skill the tool named, and a name is not a run. */ -export interface CostReportEnvelopeFlowRow { - readonly flow?: string; - readonly attribution: FlowAttributionSource; - readonly started_at?: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** Every day the period spans, in order, whether or not a record landed on it - a day with - * nothing is a row of zeros, never an omitted row. */ -export interface CostReportEnvelopeDayRow { - readonly day: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** `person` is the canonical identifier, present only where `resolution` is `"mapped"`; an - * unresolved row's raw identifier lives in `identities` instead, which always carries what - * produced the row so a person line is traceable back to its evidence. */ -export interface CostReportEnvelopePersonRow { - readonly resolution: PersonResolution; - readonly person?: string; - readonly display_name?: string; - readonly identities: readonly string[]; - readonly totals: CostReportEnvelopeTotals; -} - -/** What the read could not do, travelling with what it did: a total assembled from a partial - * read is indistinguishable from a complete one unless these come with it. */ -export interface CostReportEnvelopeRead { - readonly undated_records: number; - readonly unreadable_lines: number; - /** `"unreadable"` for a declared identity file that could not be read back, `"absent"` for - * none declared at all. Absent entirely when the identity was read back fine. */ - readonly identity_unusable?: PersonIdentityUnusableCause; -} - -/** One period's report in the shape a program reads, field names snake_case to match the - * stored record a consumer may already parse. Every counter is optional for the same reason - * it is optional there: absent means never observed, a different fact from zero. */ -export interface CostReportEnvelope { - readonly cost_report_version: number; - /** The period as it resolved, absolutely — never as it was asked for. */ - readonly period: { readonly from_day: string; readonly to_day: string }; - /** Whether the project switch is on right now, carried so `--json` and `--axis` say what - * the terminal rendering does. A switch off and a genuinely empty period read identically - * in every count below, and only this field tells them apart. */ - readonly measurement_enabled: boolean; - readonly task?: string; - /** Only the generic filters actually given - `task` above keeps its own field. Absent for - * an unfiltered period. */ - readonly filters?: CostReportFilters; - /** Present only when a filter, never the period itself, emptied this selection - naming - * which one, and whether its value was ever known at all. */ - readonly empty_selection?: CostReportEmptySelection; - readonly sessions: number; - readonly totals: CostReportEnvelopeTotals; - /** Per session, and never broken down by step: no active-time measure on any tool - * carries a step attribute. Absent when no record carried it. */ - readonly active_time_s?: number; - readonly by_step: readonly CostReportEnvelopeStepRow[]; - readonly by_model: readonly CostReportEnvelopeModelRow[]; - readonly by_tool: readonly CostReportEnvelopeToolRow[]; - readonly by_project: readonly CostReportEnvelopeProjectRow[]; - readonly by_task: readonly CostReportEnvelopeTaskRow[]; - readonly by_backlog: readonly CostReportEnvelopeBacklogRow[]; - readonly by_flow: readonly CostReportEnvelopeFlowRow[]; - /** One row per agent that ran, `agent` absent on the main thread's own row. */ - readonly by_agent: readonly CostReportEnvelopeAgentRow[]; - readonly by_prompt: readonly CostReportEnvelopePromptRow[]; - /** Every day the period spans, always - a long period stays readable by how the text - * rendering chooses to show it, never by what this envelope omits. */ - readonly by_day: readonly CostReportEnvelopeDayRow[]; - /** Mapped people first, then every unplaced identity, then the one row for records - * carrying none at all. */ - readonly by_person: readonly CostReportEnvelopePersonRow[]; - /** All four strengths, always, strongest first. */ - readonly attribution: readonly CostReportEnvelopeAttributionRow[]; - /** Present only alongside `task`: an unfiltered period carries no per-record task - * identity to break down. */ - readonly task_attribution?: readonly CostReportEnvelopeTaskAttributionRow[]; - readonly read: CostReportEnvelopeRead; -} - -function supply(from: TelemetryRouteSupply | null): CostReportEnvelopeRouteSupply | null { - return from === null - ? null - : { - token_counters: from.tokenCounters, - amount: from.amount, - tool_stated_step: from.toolStatedStep, - agent_name: from.agentName, - }; -} - -function capability(from: CostReport["byTools"][number]["capability"]) { - return { - local_read: supply(from.localRead), - export: supply(from.export), - journal_attributable: from.journalAttributable, - task_attributable: from.taskAttributable, - }; -} - -function toolRow(row: CostReport["byTools"][number]): CostReportEnvelopeToolRow { - return { - tool: row.tool, - coverage: row.coverage, - ...(row.reason === undefined ? {} : { reason: row.reason }), - capability: capability(row.capability), - totals: totals(row.totals), - ...(row.sessionTotals === undefined ? {} : { session_totals: totals(row.sessionTotals) }), - }; -} - -function stepRow(row: CostReport["bySteps"][number]): CostReportEnvelopeStepRow { - return { - ...(row.step === undefined ? {} : { step: row.step }), - attribution: row.attribution, - totals: totals(row.totals), - }; -} - -function totals(from: CostTotals): CostReportEnvelopeTotals { - return { - requests: from.requests, - ...(from.costMicroUsd === undefined ? {} : { cost_micro_usd: from.costMicroUsd }), - ...(from.inputTokens === undefined ? {} : { input_tokens: from.inputTokens }), - ...(from.outputTokens === undefined ? {} : { output_tokens: from.outputTokens }), - ...(from.cacheReadTokens === undefined ? {} : { cache_read_tokens: from.cacheReadTokens }), - ...(from.cacheCreationTokens === undefined - ? {} - : { cache_creation_tokens: from.cacheCreationTokens }), - }; -} - -function projectRow(row: CostReport["byProjects"][number]): CostReportEnvelopeProjectRow { - return { - ...(row.project === undefined ? {} : { project: row.project }), - totals: totals(row.totals), - }; -} - -function modelRow(row: CostReport["byModels"][number]): CostReportEnvelopeModelRow { - return { - ...(row.model === undefined ? {} : { model: row.model }), - totals: totals(row.totals), - }; -} - -function taskRow(row: CostReport["byTasks"][number]): CostReportEnvelopeTaskRow { - return { - ...(row.task === undefined ? {} : { task: row.task }), - ...(row.attribution === undefined ? {} : { attribution: row.attribution }), - ...(row.reason === undefined ? {} : { reason: row.reason }), - totals: totals(row.totals), - }; -} - -function backlogRow(row: CostReport["byBacklog"][number]): CostReportEnvelopeBacklogRow { - return { - ...(row.backlog === undefined ? {} : { backlog: row.backlog }), - ...(row.declaration === undefined ? {} : { declaration: row.declaration }), - ...(row.reason === undefined ? {} : { reason: row.reason }), - totals: totals(row.totals), - }; -} - -function agentRow(row: CostReport["byAgents"][number]): CostReportEnvelopeAgentRow { - return { - ...(row.agent === undefined ? {} : { agent: row.agent }), - attribution: row.attribution, - totals: totals(row.totals), - }; -} - -function promptRow(row: CostReport["byPrompts"][number]): CostReportEnvelopePromptRow { - return { - ...(row.prompt === undefined ? {} : { prompt: row.prompt }), - ...(row.startedAt === undefined ? {} : { started_at: row.startedAt }), - totals: totals(row.totals), - }; -} - -function flowRow(row: CostReport["byFlows"][number]): CostReportEnvelopeFlowRow { - return { - ...(row.flow === undefined ? {} : { flow: row.flow }), - attribution: row.attribution, - ...(row.startedAt === undefined ? {} : { started_at: row.startedAt }), - totals: totals(row.totals), - }; -} - -function personRow(row: CostReport["byPeople"][number]): CostReportEnvelopePersonRow { - return { - resolution: row.resolution, - ...(row.person === undefined ? {} : { person: row.person }), - ...(row.displayName === undefined ? {} : { display_name: row.displayName }), - identities: row.identities, - totals: totals(row.totals), - }; -} - -function attributionRow( - row: CostReport["attributionMix"][number] -): CostReportEnvelopeAttributionRow { - return { attribution: row.attribution, totals: totals(row.totals) }; -} - -/** Present only alongside `task`: an unfiltered period carries no per-record task identity - * to break down. */ -function taskAttribution( - taskAttributionMix: CostReport["taskAttributionMix"] -): Pick { - if (taskAttributionMix === undefined) return {}; - return { - task_attribution: taskAttributionMix.map((row) => ({ - attribution: row.attribution, - totals: totals(row.totals), - })), - }; -} - -function readSummary(report: CostReport): CostReportEnvelopeRead { - return { - undated_records: report.undatedRecords, - unreadable_lines: report.unreadableLines, - ...(report.identityUnusableCause === undefined - ? {} - : { identity_unusable: report.identityUnusableCause }), - }; -} - -/** Every `by_*` breakdown together. */ -function breakdownFields( - report: CostReport -): Pick< - CostReportEnvelope, - | "by_step" - | "by_model" - | "by_tool" - | "by_project" - | "by_task" - | "by_backlog" - | "by_flow" - | "by_agent" - | "by_prompt" - | "by_day" - | "by_person" -> { - return { - by_step: report.bySteps.map(stepRow), - by_model: report.byModels.map(modelRow), - by_tool: report.byTools.map(toolRow), - by_project: report.byProjects.map(projectRow), - by_task: report.byTasks.map(taskRow), - by_backlog: report.byBacklog.map(backlogRow), - by_flow: report.byFlows.map(flowRow), - by_agent: report.byAgents.map(agentRow), - by_prompt: report.byPrompts.map(promptRow), - by_day: report.byDays.map((row) => ({ day: row.day, totals: totals(row.totals) })), - by_person: report.byPeople.map(personRow), - }; -} - -/** A rendering, never a second computation: every figure comes from the `CostReport` handed - * in and nothing is derived on the way through, since two ways of computing one number is - * how they start disagreeing. Pure - no clock, no filesystem, no printing. */ -export function toCostReportEnvelope(report: CostReport): CostReportEnvelope { - return { - cost_report_version: COST_REPORT_ENVELOPE_VERSION, - period: { from_day: report.fromDay, to_day: report.toDay }, - measurement_enabled: report.measurementEnabled, - ...(report.task === undefined ? {} : { task: report.task }), - ...(report.filters === undefined ? {} : { filters: report.filters }), - ...(report.emptySelection === undefined ? {} : { empty_selection: report.emptySelection }), - sessions: report.sessions, - totals: totals(report.totals), - ...(report.activeTimeSeconds === undefined ? {} : { active_time_s: report.activeTimeSeconds }), - ...breakdownFields(report), - attribution: report.attributionMix.map(attributionRow), - ...taskAttribution(report.taskAttributionMix), - read: readSummary(report), - }; -} diff --git a/cli/src/contexts/telemetry/domain/cost-report.ts b/cli/src/contexts/telemetry/domain/cost-report.ts deleted file mode 100644 index 20bce9055..000000000 --- a/cli/src/contexts/telemetry/domain/cost-report.ts +++ /dev/null @@ -1,760 +0,0 @@ -/** An axis owns its key, sentinels, group shape and order under `report/axes/`; this file - * owns the accumulators and the single pass that fills them. The edge back from `report/**` - * is `import type` only - a value import there recreates the cycle this split avoids. */ - -import type { TelemetryRouteSupply } from "../../../kernel/measurement.js"; -import type { AiToolId } from "../../../kernel/tool.js"; -import type { FlowAttributionSource, FlowInterval } from "./flow-attribution.js"; -import { type PersonResolution, type ResolvedPerson, resolvePerson } from "./person-resolution.js"; -import type { PersonIdentity } from "./ports/person-identity-reader.js"; -import { addToDayGroup, dayRange, dayRows } from "./report/axes/day-rows.js"; -import { - allFlowIntervalsByVendorId, - type FlowRowKey, - flowKeyOf, - flowRows, -} from "./report/axes/flow-rows.js"; -import { - type PersonGroup, - type PersonRowKey, - personGroupKey, - personRawIdOf, - personRows, -} from "./report/axes/person-rows.js"; -import { - type AgentKey, - agentKeyOf, - agentNamingTools, - agentRows, - type ModelKey, - modelKeyOf, - modelRows, - type ProjectKey, - type PromptGroup, - type PromptKey, - projectKeyOf, - projectRows, - promptKeyOf, - promptRows, -} from "./report/axes/record-stated-rows.js"; -import { attributionRows, type StepGroup, stepRowKey, stepRows } from "./report/axes/step-rows.js"; -import { - allTaskIntervalsByVendorId, - type BacklogRowKey, - backlogKeyOf, - backlogRows, - type TaskGroup, - type TaskRow, - taskAttributionRows, - taskRowKeyOf, - taskRowOf, - taskRows, -} from "./report/axes/task-rows.js"; -import { buildToolRows, declaredToolsInScope } from "./report/axes/tool-rows.js"; -import { COUNTER_FIELDS, COUNTER_SOURCE, type CounterField } from "./report/record-counters.js"; -import { collapseBilledRequests, collapseSupersededTurns } from "./report/record-reconciliation.js"; -import { - activeFilters, - emptySelectionOf, - selectionStages, - type TaskMembership, - taskAttributionOf, - taskMembership, -} from "./report/report-selection.js"; -import type { StepAttributionSource } from "./step-attribution.js"; -import type { - TaskAttributionSource, - TaskInterval, - TaskUnattributedReason, -} from "./task-attribution.js"; -import type { TaskBacklogDeclaration } from "./task-backlog-link.js"; -import type { TaskIdentity } from "./task-identity.js"; -import type { TelemetrySinkRecord } from "./telemetry-sink-record.js"; - -/** Money is carried as whole micro-dollars, never as the floating amount a record stores: - * the same amounts summed in two groupings differ in the last bits, so no report over - * floats reconciles exactly. Rounding once, on the way in, makes every sum after it exact. */ -const MICRO_USD_PER_USD = 1e6; - -export function toMicroUsd(costUsd: number): number { - return Math.round(costUsd * MICRO_USD_PER_USD); -} - -export function fromMicroUsd(microUsd: number): number { - return microUsd / MICRO_USD_PER_USD; -} - -/** Every counter is optional and an absent one means *never observed*, a different fact - * from zero: a tool whose files carry no amount has an unknown cost, not a free one. - * `requests` alone is never absent - a group exists because records are in it. */ -export interface CostTotals { - readonly requests: number; - readonly costMicroUsd?: number; - readonly inputTokens?: number; - readonly outputTokens?: number; - readonly cacheReadTokens?: number; - readonly cacheCreationTokens?: number; -} - -/** Keyed by the step *and* the strength of its attribution, never the step alone: the same - * skill reached from the tool's own statement and from a journal interval is two claims, and - * merging them presents an inference as a measurement. `step` is absent exactly when - * `attribution` is `"unattributed"`, which names what nothing could say. */ -export interface CostReportStepRow { - readonly step?: string; - readonly attribution: StepAttributionSource; - readonly totals: CostTotals; -} - -/** Largest first, plus one row for what named none - `model` absent there. Codex and - * OpenCode both permit a request record with no model, so that row is what keeps `byModels` - * reconciling to the total. */ -export interface CostReportModelRow { - readonly model?: string; - readonly totals: CostTotals; -} - -/** `main-thread` is a measurement - the tool names agents and said this record belongs to - * none of them; `not-stated` is a tool whose route never names an agent at all, where - * reading a main thread would assert a fact nothing observed. */ -export type AgentAttributionSource = "tool-stated" | "main-thread" | "not-stated"; - -/** One agent's own share of a period, `agent` absent unless `attribution` is `tool-stated`. - * The limit it lives with: a subagent line carrying no agent name reads as the main thread, - * since nothing on the stored record separates the two, and no record already stored could - * gain the field that would (see `storeNewCandidates`). */ -export interface CostReportAgentRow { - readonly agent?: string; - readonly attribution: AgentAttributionSource; - readonly totals: CostTotals; -} - -/** `prompt` absent on the row for records that named none, and a record stored without one - * stays unnamed however often the sink is re-read, since `storeNewCandidates` fixes a - * record's field set the first time it sees the turn. Only a named prompt gets `startedAt`: - * the unnamed row is drawn from many turns, and was never one unit with a start. */ -export interface CostReportPromptRow { - readonly prompt?: string; - readonly startedAt?: string; - readonly totals: CostTotals; -} - -/** `covered` with no records is a tool that could have been read and did nothing this - * period; `not-covered` is a tool nothing here can read at all. A consumer prints the second - * as its reason, never as a zero. */ -export type CostReportToolCoverage = "covered" | "not-covered"; - -/** Travels beside the figures so a consumer branches on a declared capability rather than - * on whether a number happened to be present - the inference that turns a limit into a zero. - * `null` means the route is not declared at all, not a declared route supplying nothing. */ -export interface CostReportToolCapability { - readonly localRead: TelemetryRouteSupply | null; - readonly export: TelemetryRouteSupply | null; - /** False means two things at once: no step can be derived from an interval, and a read - * sweeping the journal never reaches one of this tool's sessions - so a perfectly readable - * tool can report nothing, a limit otherwise indistinguishable from doing no work. */ - readonly journalAttributable: boolean; - readonly taskAttributable: boolean; -} - -export interface CostReportToolDeclaration { - readonly tool: AiToolId; - readonly coverage: CostReportToolCoverage; - /** Why it is not covered, or what a covered tool's figures cannot be used for. Comes from - * the tool's own declaration; this module never writes one. */ - readonly reason?: string; - readonly capability: CostReportToolCapability; -} - -export interface CostReportToolRow { - readonly tool: AiToolId; - readonly coverage: CostReportToolCoverage; - readonly reason?: string; - readonly capability: CostReportToolCapability; - readonly totals: CostTotals; - /** A local-read `kind: "session"` total, present only for a tool whose own file yields an - * already-complete session figure rather than per-request records - today, only Copilot. - * Never folded into `totals`: the two-kinds rule forbids treating one as the other. */ - readonly sessionTotals?: CostTotals; -} - -/** How much of the broken-down total each strength accounts for - four figures that sum to - * the total rather than a sentence saying attribution is approximate, since only the four - * can be asserted. */ -export interface CostReportAttributionRow { - readonly attribution: StepAttributionSource; - readonly totals: CostTotals; -} - -/** The same idea as `CostReportAttributionRow`, one axis over: how much of a `--task` - * report's total came from a declared interval versus a written file. */ -export interface CostReportTaskAttributionRow { - readonly attribution: TaskAttributionSource; - readonly totals: CostTotals; -} - -/** Largest first, plus one row for what named none - `project` absent there. Never folded - * into a neighbour: that would place a figure that was never placed. */ -export interface CostReportProjectRow { - readonly project?: string; - readonly totals: CostTotals; -} - -/** Keyed on the declared interval a record's own moment falls in - never on the whole-session - * written-path inference the `--task` filter reads, which would let one session's records - * land in more than one row. A record in no declared interval carries `reason` instead, one - * row per `TASK_UNATTRIBUTED_REASONS` entry present: two different gaps are not one gap. */ -export interface CostReportTaskRow { - readonly task?: TaskIdentity; - readonly attribution?: TaskAttributionSource; - readonly reason?: TaskUnattributedReason; - readonly totals: CostTotals; -} - -/** Grouped one level above `CostReportTaskRow`, on the same per-record task membership - * `byTasks` computes. Where `backlog` is absent, exactly one of `declaration` (the task - * declares no item, or its declaration could not be parsed - counted either way) and - * `reason` (the record belongs to no task) says why: never both, never neither. */ -export interface CostReportBacklogRow { - readonly backlog?: string; - readonly declaration?: "none" | "unreadable"; - readonly reason?: TaskUnattributedReason; - readonly totals: CostTotals; -} - -/** Keyed on the closed `FlowInterval` a record's moment falls inside, never on `flow` (the - * orchestrating skill's name) alone: one session running that skill twice must stay two - * rows, told apart by `startedAt`. The limit it lives with: a skill run by hand while a flow - * is open still counts inside it, since the journal writes the same `step_start` for both. */ -export interface CostReportFlowRow { - readonly flow?: string; - readonly attribution: FlowAttributionSource; - readonly startedAt?: string; - readonly totals: CostTotals; -} - -/** `person` is the canonical `personId`, present only when `resolution` is `"mapped"`; an - * `"unresolved"` row's raw identifier lives in `identities` instead, nobody having claimed a - * canonical form for it. `identities` always carries what produced the row, so a line naming - * a person is traceable back to its evidence without a second lookup. */ -export interface CostReportPersonRow { - readonly resolution: PersonResolution; - readonly person?: string; - readonly displayName?: string; - readonly identities: readonly string[]; - readonly totals: CostTotals; -} - -/** Every day the period spans in order, whether or not a record landed on it. A day with - * nothing is a row of zeros - the one place here a zero is the measurement, because an - * omitted row would read as continuity a gap is not. */ -export interface CostReportDayRow { - readonly day: string; - readonly totals: CostTotals; -} - -/** One session's journal, reduced to what a report needs. Assembling it is the caller's - * job; this module never opens a file, and `taskIntervals` arrives already built once per - * session rather than re-derived per record. */ -export interface CostReportSessionJournal { - readonly vendorId: string; - readonly tool: string; - readonly projectId?: string; - readonly writtenPaths: readonly string[]; - readonly taskIntervals: readonly TaskInterval[]; - readonly flowIntervals: readonly FlowInterval[]; - /** The first and last moment this journal's own lines witnessed - the bound the - * written-file route infers inside and never outside, since a journal lost and recreated - * mid-session witnesses far less time than its session produced records for. Absent when - * no line carried a moment this reader could parse: nothing witnessed, nothing inferred. */ - readonly witnessed?: { readonly fromMs: number; readonly toMs: number }; -} - -/** The four dimensions that narrow on an equal record field - `task` keeps its own route and - * its own top-level field. Every one composes with the others, and with `task`, by `and`: - * two given narrow to their intersection, never their union. */ -export interface CostReportFilters { - readonly project?: string; - readonly step?: string; - readonly model?: string; - readonly tool?: string; -} - -export type CostReportFilterName = keyof CostReportFilters | "task"; - -/** Every value a filterable field has carried anywhere the caller looked, not only in this - * period - what lets an empty selection tell a value nobody ever recorded apart from one - * that simply had no work here. */ -export interface CostReportKnownValues { - readonly projects: ReadonlySet; - readonly steps: ReadonlySet; - readonly models: ReadonlySet; -} - -/** The filter that narrowed a non-empty selection to nothing - never the period itself, - * which is an honest zero. `combination` is present only when the value matched something - * before any generic filter ran, so the emptiness comes from an intersection. */ -export interface CostReportEmptySelection { - readonly filter: CostReportFilterName; - readonly value: string; - readonly known: boolean; - readonly combination?: boolean; -} - -/** Why this machine's own identity could not be used to resolve records - two causes rather - * than one boolean, so "the file exists but could not be read" stays apart from "nobody - * declared one at all". */ -export type PersonIdentityUnusableCause = "unreadable" | "absent"; - -export interface CostReportInput { - readonly fromDay: string; - readonly toDay: string; - readonly records: readonly TelemetrySinkRecord[]; - readonly journals: readonly CostReportSessionJournal[]; - readonly declaredTools: readonly CostReportToolDeclaration[]; - /** Records carrying no moment at all - counted and named, never placed in the period. */ - readonly undatedRecords: number; - /** Lines the read could not parse. A report built from a partial read looks exactly like - * one built from a whole read unless this travels with it. */ - readonly unreadableLines: number; - /** Restrict to the sessions that wrote into this task. Absent means the whole period: a - * task is a filter over one, and work touching no task folder is still fully reportable. */ - readonly task?: TaskIdentity; - /** Any of `project`, `step`, `model` and `tool`, each optional and composing with `task` - * and each other by `and`. */ - readonly filters?: CostReportFilters; - /** Every distinct task identity this period's records could fall inside, resolved once - * each to its folder's declaration - gathered by the caller so the domain stays free of a - * filesystem, and never re-resolved per record. A task this map cannot name reads as - * `{ kind: "none" }`, so a missing entry never drops a record. */ - readonly taskBacklogDeclarations?: ReadonlyMap; - /** Where a generic filter's value has ever been seen - absent when the caller has none - * to offer, which reads the same as a filter never matching it elsewhere. */ - readonly knownValues?: CostReportKnownValues; - /** This machine's own identity, arriving as data so the domain stays free of where the - * identity file lives. Absent or `null` both mean none was declared, which resolves every - * identifier as `unresolved` rather than failing the report. */ - readonly identity?: PersonIdentity | null; - /** `"unreadable"` for a declared identity file that could not be read back, `"absent"` for - * none declared. Either way costs the resolution alone: every record is still counted, - * every identifier reported `unresolved`. Absent when the identity was read back fine. */ - readonly identityUnusableCause?: PersonIdentityUnusableCause; - /** Whether the project switch is on right now, as data rather than a read this pure - * function performs. Required, never defaulted: a default would be the silent "on" this - * field exists to rule out. */ - readonly measurementEnabled: boolean; -} - -export interface CostReport { - readonly fromDay: string; - readonly toDay: string; - readonly task?: TaskIdentity; - /** Only the generic filters actually given, in a fixed order - `task` keeps its own - * field above, unchanged. Absent for an unfiltered period. */ - readonly filters?: CostReportFilters; - /** Present only when a filter - never the period itself - is what emptied this - * selection. */ - readonly emptySelection?: CostReportEmptySelection; - readonly sessions: number; - readonly totals: CostTotals; - /** From `kind: "session"` records alone and never broken down by step: no active-time - * measure on any tool carries a step attribute. Absent when no record carried it. */ - readonly activeTimeSeconds?: number; - readonly bySteps: readonly CostReportStepRow[]; - readonly byModels: readonly CostReportModelRow[]; - readonly byAgents: readonly CostReportAgentRow[]; - readonly byPrompts: readonly CostReportPromptRow[]; - readonly byTools: readonly CostReportToolRow[]; - readonly byProjects: readonly CostReportProjectRow[]; - readonly byTasks: readonly CostReportTaskRow[]; - readonly byBacklog: readonly CostReportBacklogRow[]; - readonly byFlows: readonly CostReportFlowRow[]; - readonly byDays: readonly CostReportDayRow[]; - /** Mapped people first, then every unplaced identity, then the one row for records - * carrying none. Largest first within each group; never merged across the three. */ - readonly byPeople: readonly CostReportPersonRow[]; - readonly attributionMix: readonly CostReportAttributionRow[]; - /** Present only alongside `task`: an unfiltered period carries no per-record task identity - * to break down. */ - readonly taskAttributionMix?: readonly CostReportTaskAttributionRow[]; - readonly undatedRecords: number; - readonly unreadableLines: number; - /** Which cause made this machine's own identity unusable for resolving records. Absent - * when the identity was read back fine. */ - readonly identityUnusableCause?: PersonIdentityUnusableCause; - /** Whether the project switch is on right now - never inferred from whether any record was - * found, since an empty period and a switched-off one are different facts. Always concrete - * here, unlike the optional input field it is resolved from. */ - readonly measurementEnabled: boolean; -} - -/** Accumulates a group while keeping "never observed" distinct from "observed as zero". - * A field stays absent until some record in the group carries it. */ -export class TotalsAccumulator { - private requests = 0; - private costMicroUsd: number | undefined; - private readonly counters = new Map(); - - add(record: TelemetrySinkRecord): void { - this.requests += 1; - // `typeof`, not `!== undefined`: a record read off disk need not hold the type its field - // declares, and `JSON.stringify(NaN)` is `null`, which would read as a known, free cost. - if (typeof record.cost_usd === "number") { - this.costMicroUsd = (this.costMicroUsd ?? 0) + toMicroUsd(record.cost_usd); - } - this.addTokensOnly(record); - } - - /** Never touches `requests` or `cost_usd`: a `kind: "session"` local-read total is not a - * billed request, and the tool never states a cost for one. */ - addTokensOnly(record: TelemetrySinkRecord): void { - for (const field of COUNTER_FIELDS) { - const value = record[COUNTER_SOURCE[field]]; - if (typeof value === "number") { - this.counters.set(field, (this.counters.get(field) ?? 0) + value); - } - } - } - - build(): CostTotals { - const counters: Partial> = {}; - for (const field of COUNTER_FIELDS) { - const value = this.counters.get(field); - if (value !== undefined) counters[field] = value; - } - return { - requests: this.requests, - ...(this.costMicroUsd === undefined ? {} : { costMicroUsd: this.costMicroUsd }), - ...counters, - }; - } -} - -function accumulateInto( - groups: Map, - key: K, - record: TelemetrySinkRecord, - apply: (accumulator: TotalsAccumulator) => void = (accumulator) => accumulator.add(record) -): void { - const existing = groups.get(key); - if (existing) { - apply(existing); - return; - } - const created = new TotalsAccumulator(); - apply(created); - groups.set(key, created); -} - -function addToStepGroup(groups: Map, record: TelemetrySinkRecord): void { - const key = stepRowKey(record); - const existing = groups.get(key); - if (existing) { - existing.totals.add(record); - return; - } - const created: StepGroup = { - attribution: record.step_attribution, - ...(record.step === undefined ? {} : { step: record.step }), - totals: new TotalsAccumulator(), - }; - created.totals.add(record); - groups.set(key, created); -} - -/** Mirrors `addToStepGroup`, which folds that axis' own pairs the same way. */ -function addToTaskGroup( - groups: Map, - row: TaskRow, - record: TelemetrySinkRecord -): void { - const key = taskRowKeyOf(row); - const existing = groups.get(key); - if (existing) { - existing.totals.add(record); - return; - } - const created: TaskGroup = - typeof row === "string" - ? { reason: row, totals: new TotalsAccumulator() } - : { task: row.task, attribution: row.attribution, totals: new TotalsAccumulator() }; - created.totals.add(record); - groups.set(key, created); -} - -function addToPersonGroup( - groups: Map, - record: TelemetrySinkRecord, - resolved: ResolvedPerson -): void { - const key = personGroupKey(resolved); - const existing = groups.get(key); - if (existing) { - existing.totals.add(record); - return; - } - const created: PersonGroup = { resolved, totals: new TotalsAccumulator() }; - created.totals.add(record); - groups.set(key, created); -} - -function addToPromptGroup(groups: Map, record: TelemetrySinkRecord): void { - const key = promptKeyOf(record); - const group = groups.get(key) ?? { totals: new TotalsAccumulator() }; - group.totals.add(record); - const atMs = - record.event_timestamp === undefined ? Number.NaN : Date.parse(record.event_timestamp); - if (!Number.isNaN(atMs) && (group.earliestMs === undefined || atMs < group.earliestMs)) { - group.earliestMs = atMs; - } - groups.set(key, group); -} - -/** Every group one pass over the records fills, kept together so the pass reads as one - * decision per record rather than parallel loops over the same list. */ -interface Groups { - readonly totals: TotalsAccumulator; - readonly steps: Map; - readonly models: Map; - readonly agents: Map; - readonly prompts: Map; - readonly tools: Map; - readonly toolSessionTotals: Map; - readonly attributions: Map; - readonly taskAttributions: Map; - readonly projects: Map; - readonly tasks: Map; - readonly backlog: Map; - readonly flows: Map; - readonly people: Map; - readonly days: Map; - activeTimeSeconds?: number; -} - -function emptyGroups(fromDay: string, toDay: string): Groups { - const days = new Map(); - for (const day of dayRange(fromDay, toDay)) days.set(day, new TotalsAccumulator()); - return { - totals: new TotalsAccumulator(), - steps: new Map(), - models: new Map(), - agents: new Map(), - prompts: new Map(), - tools: new Map(), - toolSessionTotals: new Map(), - attributions: new Map(), - taskAttributions: new Map(), - projects: new Map(), - tasks: new Map(), - backlog: new Map(), - flows: new Map(), - people: new Map(), - days, - }; -} - -/** Active time is the one quantity taken from a `"session"` record: no `"request"` record on - * any tool measured so far carries it, and a `"session"` record's money and tokens are a - * flush window's own delta of quantities the request records already report in full, so they - * are kept off `totals`, `bySteps` and `byDays` whatever the route. */ -function accumulateSessionRecord(groups: Groups, record: TelemetrySinkRecord): void { - // `typeof`, not `!== undefined`: `parseTelemetrySinkLine` casts everything but the schema - // version, so `null` would read as an observed zero and a string would concatenate into - // the running total and reach the terminal as `NaN` minutes. - if (typeof record.active_time_s === "number") { - groups.activeTimeSeconds = (groups.activeTimeSeconds ?? 0) + record.active_time_s; - } - if (record.provenance === "local-read") { - accumulateInto(groups.toolSessionTotals, record.tool, record, (accumulator) => - accumulator.addTokensOnly(record) - ); - } -} - -/** Everything one record needs to be placed on every axis, resolved once per report rather - * than once per record. */ -interface RecordContext { - readonly membership: TaskMembership | null; - readonly taskIntervalsByVendorId: ReadonlyMap; - readonly flowIntervalsByVendorId: ReadonlyMap; - readonly journalsByVendorId: ReadonlyMap; - readonly identity: PersonIdentity | null; - readonly taskBacklogDeclarations: ReadonlyMap | undefined; - readonly namesAgents: (tool: AiToolId) => boolean; -} - -function accumulateRequestRecord( - groups: Groups, - record: TelemetrySinkRecord, - context: RecordContext -): void { - groups.totals.add(record); - addToStepGroup(groups.steps, record); - accumulateInto(groups.attributions, record.step_attribution, record); - accumulateInto(groups.tools, record.tool, record); - accumulateInto(groups.models, modelKeyOf(record), record); - accumulateInto(groups.agents, agentKeyOf(record, context.namesAgents), record); - addToPromptGroup(groups.prompts, record); - accumulateInto(groups.projects, projectKeyOf(record), record); - const taskRow = taskRowOf(record, context.taskIntervalsByVendorId, context.journalsByVendorId); - addToTaskGroup(groups.tasks, taskRow, record); - accumulateInto(groups.backlog, backlogKeyOf(taskRow, context.taskBacklogDeclarations), record); - accumulateInto(groups.flows, flowKeyOf(record, context.flowIntervalsByVendorId), record); - addToPersonGroup(groups.people, record, resolvePerson(context.identity, personRawIdOf(record))); - addToDayGroup(groups.days, record); - const { membership } = context; - const attribution = membership === null ? undefined : taskAttributionOf(record, membership); - if (attribution !== undefined) accumulateInto(groups.taskAttributions, attribution, record); -} - -function accumulate( - records: readonly TelemetrySinkRecord[], - fromDay: string, - toDay: string, - membership: TaskMembership | null, - journals: readonly CostReportSessionJournal[], - identity: PersonIdentity | null, - taskBacklogDeclarations: ReadonlyMap | undefined, - declaredTools: readonly CostReportToolDeclaration[] -): Groups { - const groups = emptyGroups(fromDay, toDay); - const context: RecordContext = { - membership, - taskIntervalsByVendorId: allTaskIntervalsByVendorId(journals), - flowIntervalsByVendorId: allFlowIntervalsByVendorId(journals), - journalsByVendorId: new Map(journals.map((journal) => [journal.vendorId, journal])), - identity, - taskBacklogDeclarations, - namesAgents: agentNamingTools(declaredTools), - }; - for (const record of records) { - if (record.kind === "session") accumulateSessionRecord(groups, record); - else accumulateRequestRecord(groups, record, context); - } - return groups; -} - -/** `task`, `filters` and `emptySelection` together - the selection this report answered, as - * opposed to the figures it answered with. */ -function selectionFields( - input: CostReportInput, - emptySelection: CostReportEmptySelection | undefined -): Pick { - const filters = activeFilters(input.filters); - return { - ...(input.task === undefined ? {} : { task: input.task }), - ...(filters === undefined ? {} : { filters }), - ...(emptySelection === undefined ? {} : { emptySelection }), - }; -} - -function toolRowsInScope(input: CostReportInput, groups: Groups): readonly CostReportToolRow[] { - return buildToolRows( - declaredToolsInScope(input.declaredTools, input.filters), - groups.tools, - groups.toolSessionTotals - ); -} - -/** `undatedRecords`, `unreadableLines` and `identityUnusableCause` together - what the read - * could not do. */ -function readFields( - input: CostReportInput -): Pick< - CostReport, - "undatedRecords" | "unreadableLines" | "identityUnusableCause" | "measurementEnabled" -> { - return { - undatedRecords: input.undatedRecords, - unreadableLines: input.unreadableLines, - measurementEnabled: input.measurementEnabled, - ...(input.identityUnusableCause === undefined - ? {} - : { identityUnusableCause: input.identityUnusableCause }), - }; -} - -/** Every `by*` breakdown together. */ -function breakdownFields( - input: CostReportInput, - groups: Groups -): Pick< - CostReport, - | "bySteps" - | "byModels" - | "byAgents" - | "byPrompts" - | "byTools" - | "byProjects" - | "byTasks" - | "byBacklog" - | "byFlows" - | "byDays" - | "byPeople" -> { - return { - bySteps: stepRows(groups.steps), - byModels: modelRows(groups.models), - byAgents: agentRows(groups.agents), - byPrompts: promptRows(groups.prompts), - byTools: toolRowsInScope(input, groups), - byProjects: projectRows(groups.projects), - byTasks: taskRows(groups.tasks), - byBacklog: backlogRows(groups.backlog), - byFlows: flowRows(groups.flows), - byDays: dayRows(groups.days), - byPeople: personRows(groups.people), - }; -} - -function assembleCostReport( - input: CostReportInput, - inScope: readonly TelemetrySinkRecord[], - groups: Groups, - membership: TaskMembership | null, - emptySelection: CostReportEmptySelection | undefined -): CostReport { - return { - fromDay: input.fromDay, - toDay: input.toDay, - ...selectionFields(input, emptySelection), - sessions: new Set(inScope.map((record) => record.vendor_id)).size, - totals: groups.totals.build(), - ...(groups.activeTimeSeconds === undefined - ? {} - : { activeTimeSeconds: groups.activeTimeSeconds }), - ...breakdownFields(input, groups), - attributionMix: attributionRows(groups.attributions), - ...(membership === null - ? {} - : { taskAttributionMix: taskAttributionRows(groups.taskAttributions) }), - ...readFields(input), - }; -} - -/** One period's records and journals, reduced to a report whose every breakdown sums to the - * total it belongs to. Money and the four token counters come from `kind: "request"` records - * alone, active time from `kind: "session"` records alone: summing across the two kinds - * counts the same tokens twice and produces a total that looks right. */ -export function buildCostReport(input: CostReportInput): CostReport { - // Turn-supersede first: a still-open Codex turn is down to one record before a billed-call - // group is formed. Order is otherwise inert - the two key on disjoint fields. - const records = collapseBilledRequests(collapseSupersededTurns(input.records)); - const membership = input.task === undefined ? null : taskMembership(input.journals, input.task); - const stages = selectionStages(records, input, membership); - const emptySelection = emptySelectionOf(stages, input, membership); - const inScope = stages[stages.length - 1]?.records ?? []; - const identity = input.identity ?? null; - const groups = accumulate( - inScope, - input.fromDay, - input.toDay, - membership, - input.journals, - identity, - input.taskBacklogDeclarations, - input.declaredTools - ); - - return assembleCostReport(input, inScope, groups, membership, emptySelection); -} diff --git a/cli/src/contexts/telemetry/domain/flow-attribution.ts b/cli/src/contexts/telemetry/domain/flow-attribution.ts deleted file mode 100644 index a4b214e61..000000000 --- a/cli/src/contexts/telemetry/domain/flow-attribution.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - buildClosedIntervals, - type ClosedInterval, - type IntervalClosure, -} from "./journal-intervals.js"; -import type { - RunJournal, - RunJournalBoundary, - RunJournalFileWritten, - RunJournalStepStart, - RunJournalTaskDeclared, -} from "./ports/run-journal-reader.js"; -import { namesTheSameSkill } from "./skill-name.js"; - -/** How a record's flow came to be known - a flow an interval placed a record inside and one - * the record's own tool named are different claims. Narrower than `StepAttributionSource` - * deliberately: `prompt-matched` names a step and never a flow, so nothing here produces it. */ -export type FlowAttributionSource = "journal-interval" | "tool-stated" | "unattributed"; - -/** Which skills open a flow when their own `step_start` fires - declared, never matched from a - * plugin name. Each is named twice because `skill-detection.cjs`'s two capture routes write - * `aidd-orchestrator:01-sdlc` (Claude Code, Copilot) and the bare `SKILL.md` directory name - * (Cursor, Codex); the prefixed form alone opens no flow there, and a bare one can collide. */ -export const ORCHESTRATING_SKILLS: ReadonlySet = new Set([ - "aidd-orchestrator:00-async-dev", - "00-async-dev", - "aidd-orchestrator:01-sdlc", - "01-sdlc", - "aidd-orchestrator:02-backlog", - "02-backlog", -]); - -/** The unqualified spellings among them - the ones a reader's own project can collide with, so - * the ones `flowLimits` names. Derived rather than listed by hand, which would go stale the - * moment one is added. Sorted so the sentence reads the same on every run. */ -export function bareOrchestratingSkillNames( - skills: ReadonlySet = ORCHESTRATING_SKILLS -): readonly string[] { - return [...skills].filter((skill) => !skill.includes(":")).sort(); -} - -/** One closed flow interval: from an orchestrating skill's own `step_start` to whichever of a - * `step_end` naming that same skill or the next orchestrating `step_start` comes first, or - - * unclosed - the journal's own last witnessed moment. A `turn_end` is a pause and never closes - * one; a non-orchestrating `step_start` neither opens nor closes one. */ -export interface FlowInterval extends ClosedInterval { - readonly skill: string; - /** Whether `endMs` is a moment this journal witnessed or the cap standing in for one it - * never did - carried because `buildStepIntervals` reads it in the step axis. */ - readonly closedBy: IntervalClosure; -} - -/** Journal lines in, closed flow intervals out - the same merge and cap `buildTaskIntervals` - * uses, through the one shared walk. A `step_end` naming a *different* skill is never a closer: - * a step finishing inside the orchestration is not the orchestration finishing, and since only - * `aidd-dev:01-plan` emits that marker, most flows close on the journal's end instead. */ -export function buildFlowIntervals( - journal: RunJournal, - periodEndMs?: number -): readonly FlowInterval[] { - return buildClosedIntervals< - RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, - RunJournalStepStart, - FlowInterval - >( - [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], - periodEndMs, - (boundary): boundary is RunJournalStepStart => - boundary.type === "step_start" && ORCHESTRATING_SKILLS.has(boundary.skill), - (boundary, opener) => - boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill), - (opener, startMs, endMs, closedBy) => ({ skill: opener.skill, startMs, endMs, closedBy }) - ); -} diff --git a/cli/src/contexts/telemetry/domain/formats/claude-code-transcript.ts b/cli/src/contexts/telemetry/domain/formats/claude-code-transcript.ts deleted file mode 100644 index 461ffc244..000000000 --- a/cli/src/contexts/telemetry/domain/formats/claude-code-transcript.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { - LocalCostCandidateRecord, - TranscriptLineAccumulator, -} from "../ports/session-cost-reader.js"; - -// Field names measured against real transcripts: the unit test turns red against its fixture if -// Claude Code moves one, before a zero could be stored in the moved field's place. A subagent's -// messages are never inline in the main transcript but in their own `subagents/agent-*.jsonl`, -// which is why `CLAUDE_CODE_TRANSCRIPT_LOCATION` matches both layouts. -const VENDOR_FIELD = "sessionId"; -const TURN_FIELD = "requestId"; - -// Claude Code writes its own fabricated assistant messages with this literal in -// `message.model` — a session-limit or error notice the tool composed, billed to nobody, so -// they yield no record. The marker is the filter, never all-counters-zero: a genuinely billed -// call reading zero on every counter is still an observation, and still yields its record. -const SYNTHETIC_MODEL = ""; - -interface ClaudeUsage { - readonly input_tokens?: unknown; - readonly cache_creation_input_tokens?: unknown; - readonly cache_read_input_tokens?: unknown; - readonly output_tokens?: unknown; -} - -interface ClaudeTranscriptLine { - readonly type?: unknown; - readonly sessionId?: unknown; - readonly uuid?: unknown; - readonly parentUuid?: unknown; - readonly promptId?: unknown; - readonly requestId?: unknown; - readonly isSidechain?: unknown; - readonly timestamp?: unknown; - readonly effort?: unknown; - readonly attributionAgent?: unknown; - readonly attributionSkill?: unknown; - readonly attributionPlugin?: unknown; - readonly message?: { - readonly model?: unknown; - readonly id?: unknown; - readonly usage?: ClaudeUsage; - readonly content?: unknown; - }; -} - -interface ClaudeCounters { - readonly input_tokens: number; - readonly cache_creation_input_tokens: number; - readonly cache_read_input_tokens: number; - readonly output_tokens: number; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -/** All four or none: a partial `usage` yields no record rather than one whose missing counter - * reads as zero. */ -function readCounters(usage: ClaudeUsage | undefined): ClaudeCounters | null { - const input = asNumber(usage?.input_tokens); - const cacheCreation = asNumber(usage?.cache_creation_input_tokens); - const cacheRead = asNumber(usage?.cache_read_input_tokens); - const output = asNumber(usage?.output_tokens); - if (input === undefined || cacheCreation === undefined) return null; - if (cacheRead === undefined || output === undefined) return null; - return { - input_tokens: input, - cache_creation_input_tokens: cacheCreation, - cache_read_input_tokens: cacheRead, - output_tokens: output, - }; -} - -function buildIdentity( - line: ClaudeTranscriptLine, - vendorId: string -): Pick< - LocalCostCandidateRecord, - "vendor_id" | "vendor_field" | "turn_id" | "turn_field" | "billed_request_id" -> { - const turnId = asString(line.requestId); - return { - vendor_id: vendorId, - vendor_field: VENDOR_FIELD, - ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), - // Stated separately rather than derived from `turn_id` downstream: `turn_id` is not unique - // per billed request on every route, and a consumer collapsing two records into one must - // never key on a field carrying that caveat. - ...(turnId !== undefined ? { billed_request_id: turnId } : {}), - }; -} - -// `agent_name` matches what the export path sets, so a local-read subagent record differs from -// an exported one by `provenance` alone. `attributionSkill` is omitted, never nulled, both when -// no skill runs and on a version predating it, so its absence yields no `step` at all rather -// than asserting "no skill ran"; `attributionPlugin` is read only alongside it. -function buildOptionalFields( - line: ClaudeTranscriptLine -): Pick< - LocalCostCandidateRecord, - "model" | "effort" | "event_timestamp" | "agent_name" | "step" | "step_plugin" -> { - const model = asString(line.message?.model); - const effort = asString(line.effort); - const timestamp = asString(line.timestamp); - const agentName = line.isSidechain === true ? asString(line.attributionAgent) : undefined; - const step = asString(line.attributionSkill); - const stepPlugin = step !== undefined ? asString(line.attributionPlugin) : undefined; - return { - ...(model !== undefined ? { model } : {}), - ...(effort !== undefined ? { effort } : {}), - ...(timestamp !== undefined ? { event_timestamp: timestamp } : {}), - ...(agentName !== undefined ? { agent_name: agentName } : {}), - ...(step !== undefined ? { step } : {}), - ...(stepPlugin !== undefined ? { step_plugin: stepPlugin } : {}), - }; -} - -function buildRecord( - line: ClaudeTranscriptLine, - vendorId: string, - counters: ClaudeCounters -): LocalCostCandidateRecord { - return { - kind: "request", - ...buildIdentity(line, vendorId), - ...buildOptionalFields(line), - input_tokens: counters.input_tokens, - output_tokens: counters.output_tokens, - cache_read_tokens: counters.cache_read_input_tokens, - cache_creation_tokens: counters.cache_creation_input_tokens, - }; -} - -/** One JSONL line as an object, or `null` for a blank or unparseable one. Shared by the - * billed-turn parser and the link walk, so a line either reaches both or neither. */ -function parseLine(line: string): ClaudeTranscriptLine | null { - const trimmed = line.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as ClaudeTranscriptLine; - } catch { - return null; - } -} - -function uuidOf(line: string): string | undefined { - const parsed = parseLine(line); - return parsed === null ? undefined : asString(parsed.uuid); -} - -/** Only a `Skill` call names a step: every other tool call is work done inside the step already - * running, and reading one as a start would name a skill for a prompt that invoked none. - * `input.skill` is the same field `skill-detection.cjs` reads out of the hook payload, so - * transcript and run journal name a step identically. */ -function skillInvokedOn(line: ClaudeTranscriptLine): string | undefined { - const content = line.message?.content; - if (!Array.isArray(content)) return undefined; - for (const part of content) { - if (typeof part !== "object" || part === null) continue; - const call = part as { type?: unknown; name?: unknown; input?: { skill?: unknown } }; - if (call.type !== "tool_use" || call.name !== "Skill") continue; - const skill = asString(call.input?.skill); - if (skill !== undefined) return skill; - } - return undefined; -} - -/** The prompt a line belongs to, walking `parentUuid` upward — a billed call and the prompt - * that caused it never share a line. `seen` bounds the walk rather than a hop count: a live - * transcript truncated mid-write can point at a parent that never arrived, or leave a cycle, - * while a hop cap would silently stop answering for a legitimately deep chain. */ -function resolvePromptId( - startUuid: string | undefined, - parents: ReadonlyMap, - prompts: ReadonlyMap -): string | undefined { - const seen = new Set(); - let current = startUuid; - while (current !== undefined && !seen.has(current)) { - const prompt = prompts.get(current); - if (prompt !== undefined) return prompt; - seen.add(current); - current = parents.get(current); - } - return undefined; -} - -/** Keyed by `message.id`: Claude Code writes a line when a message starts and another when it - * completes, so one record per line would count the same call twice. The last wins, carrying the - * complete `output_tokens` where the first holds a placeholder; the figures are never summed, - * the lines being one call restated with identical input and cache-read counters. */ -function parseAssistantLine( - line: string -): { readonly dedupeKey: string; readonly record: LocalCostCandidateRecord } | null { - const trimmed = line.trim(); - if (!trimmed) return null; - let parsed: ClaudeTranscriptLine; - try { - parsed = JSON.parse(trimmed) as ClaudeTranscriptLine; - } catch { - return null; - } - if (parsed.type !== "assistant") return null; - // Before the dedupe key is computed: a line that is not a request must not consume a - // key either, or the first real call sharing it would be dropped as a duplicate. - if (parsed.message?.model === SYNTHETIC_MODEL) return null; - const vendorId = asString(parsed.sessionId); - if (vendorId === undefined) return null; - const counters = readCounters(parsed.message?.usage); - if (!counters) return null; - const dedupeKey = asString(parsed.message?.id) ?? asString(parsed.requestId) ?? trimmed; - return { dedupeKey, record: buildRecord(parsed, vendorId, counters) }; -} - -class ClaudeCodeTranscriptAccumulator implements TranscriptLineAccumulator { - // Insertion-ordered, value replaced rather than skipped: the record keeps the position the - // call first appeared at, so a reader sees the order the calls happened. - private readonly byKey = new Map(); - // Which line each record came from, so prompts are resolved once every line has been seen: - // nothing in the format promises a parent appears earlier, and a walk run mid-stream would - // answer from a half-built map. - private readonly uuidByKey = new Map(); - // Gathered from *all* lines, not only billed ones: the chain from a call to its prompt runs - // through lines carrying no counters at all. - private readonly parents = new Map(); - private readonly prompts = new Map(); - /** In the order the transcript holds them, resolved to prompts in `build()` for the same - * reason prompts are: a walk run mid-stream reads a half-built chain. */ - private readonly skillCalls: { readonly uuid: string; readonly skill: string }[] = []; - - push(line: string): void { - this.rememberLinks(line); - const parsed = parseAssistantLine(line); - if (!parsed) return; - this.byKey.set(parsed.dedupeKey, parsed.record); - const uuid = uuidOf(line); - if (uuid !== undefined) this.uuidByKey.set(parsed.dedupeKey, uuid); - } - - /** Parsed a second time: `parseAssistantLine` answers `null` for every line that is not a - * billed assistant turn, and those are exactly the lines this walk needs. */ - private rememberLinks(line: string): void { - const parsed = parseLine(line); - if (parsed === null) return; - const uuid = asString(parsed.uuid); - if (uuid === undefined) return; - const parent = asString(parsed.parentUuid); - if (parent !== undefined) this.parents.set(uuid, parent); - const prompt = asString(parsed.promptId); - if (prompt !== undefined) this.prompts.set(uuid, prompt); - const skill = skillInvokedOn(parsed); - if (skill !== undefined) this.skillCalls.push({ uuid, skill }); - } - - /** First call wins, not the last: a prompt invoking two skills invoked the second from - * inside the first, and the prompt is named for the work it began — the same rule - * `promptToSkill` follows over the journal's own lines, so the two cannot disagree. */ - private skillByPrompt(): ReadonlyMap { - const byPrompt = new Map(); - for (const { uuid, skill } of this.skillCalls) { - const prompt = resolvePromptId(uuid, this.parents, this.prompts); - if (prompt !== undefined && !byPrompt.has(prompt)) byPrompt.set(prompt, skill); - } - return byPrompt; - } - - build(): readonly LocalCostCandidateRecord[] { - const skillByPrompt = this.skillByPrompt(); - return [...this.byKey.entries()].map(([key, record]) => { - const promptId = resolvePromptId(this.uuidByKey.get(key), this.parents, this.prompts); - if (promptId === undefined) return record; - const promptSkill = skillByPrompt.get(promptId); - return { - ...record, - prompt_id: promptId, - ...(promptSkill === undefined ? {} : { prompt_skill: promptSkill }), - }; - }); - } -} - -export function createClaudeCodeTranscriptAccumulator(): TranscriptLineAccumulator { - return new ClaudeCodeTranscriptAccumulator(); -} - -/** A wrapper around the same per-line logic, for a fixture-driven test to target directly: - * the adapter streams the accumulator instead, so a large transcript is never held whole. */ -export function mapClaudeCodeTranscriptToSinkRecords( - content: string -): readonly LocalCostCandidateRecord[] { - const accumulator = createClaudeCodeTranscriptAccumulator(); - for (const line of content.split("\n")) accumulator.push(line); - return accumulator.build(); -} diff --git a/cli/src/contexts/telemetry/domain/formats/codex-rollout.ts b/cli/src/contexts/telemetry/domain/formats/codex-rollout.ts deleted file mode 100644 index 7f1204cae..000000000 --- a/cli/src/contexts/telemetry/domain/formats/codex-rollout.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { - LocalCostCandidateRecord, - TranscriptLineAccumulator, -} from "../ports/session-cost-reader.js"; - -// A `token_count` event carries a cumulative `total_token_usage` beside this call's own -// `last_token_usage`, so summing the totals double-counts every call after the first; it names -// no model and no request id, which live on the `turn_context` opening the turn. Its -// `input_tokens` is *inclusive* of `cached_input_tokens`, unlike Claude Code's exclusive figure, -// so cached is subtracted; `reasoning_output_tokens` is a subset of `output_tokens`, never added. -const VENDOR_FIELD = "session_meta.id"; -const TURN_FIELD = "turn_id"; - -interface CodexTokenUsage { - readonly input_tokens?: unknown; - readonly cached_input_tokens?: unknown; - readonly cache_write_input_tokens?: unknown; - readonly output_tokens?: unknown; -} - -interface CodexLine { - readonly type?: unknown; - readonly timestamp?: unknown; - readonly payload?: { - readonly id?: unknown; - readonly turn_id?: unknown; - readonly model?: unknown; - readonly effort?: unknown; - readonly type?: unknown; - readonly info?: { - readonly last_token_usage?: CodexTokenUsage; - readonly total_token_usage?: CodexTokenUsage; - }; - }; -} - -interface PendingTurn { - readonly turnId: string; - readonly model?: string; - readonly effort?: string; - readonly at?: string; - inputTokens?: number; - outputTokens?: number; - cacheReadTokens?: number; - cacheCreationTokens?: number; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function parseLine(line: string): CodexLine | null { - const trimmed = line.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as CodexLine; - } catch { - return null; - } -} - -// `at` is the turn's own start, off the `turn_context` line rather than a counted event: a -// record covers a whole turn, so a moment inside it would claim a precision it does not have. -function startTurn( - payload: NonNullable, - at: string | undefined -): PendingTurn | null { - const turnId = asString(payload.turn_id); - if (turnId === undefined) return null; - return { turnId, model: asString(payload.model), effort: asString(payload.effort), at }; -} - -/** The event's own increment, never the cumulative `total_token_usage`. A metric absent from - * every event of the turn — Codex omits `cache_write_input_tokens` rather than sending zero — - * stays unset rather than summed into a fabricated zero. */ -function addUsage(pending: PendingTurn, usage: CodexTokenUsage): void { - const rawInput = asNumber(usage.input_tokens); - const cached = asNumber(usage.cached_input_tokens); - const cacheWrite = asNumber(usage.cache_write_input_tokens); - const output = asNumber(usage.output_tokens); - if (rawInput !== undefined) { - pending.inputTokens = (pending.inputTokens ?? 0) + (rawInput - (cached ?? 0)); - } - if (cached !== undefined) pending.cacheReadTokens = (pending.cacheReadTokens ?? 0) + cached; - if (cacheWrite !== undefined) { - pending.cacheCreationTokens = (pending.cacheCreationTokens ?? 0) + cacheWrite; - } - if (output !== undefined) pending.outputTokens = (pending.outputTokens ?? 0) + output; -} - -/** Codex re-emits a turn's last `token_count` verbatim, the increment arriving twice while the - * cumulative does not move — and a cumulative that has not moved cannot carry billed consumption. - * `null` when the event states none: the increment is then counted, an absent figure being no - * evidence that nothing happened. */ -function cumulativeKey(usage: CodexTokenUsage | undefined): string | null { - if (!usage) return null; - const parts = [ - asNumber(usage.input_tokens), - asNumber(usage.cached_input_tokens), - asNumber(usage.cache_write_input_tokens), - asNumber(usage.output_tokens), - ]; - if (parts.every((part) => part === undefined)) return null; - return parts.map((part) => (part === undefined ? "" : String(part))).join("/"); -} - -function hasCounters(pending: PendingTurn): boolean { - return ( - pending.inputTokens !== undefined || - pending.outputTokens !== undefined || - pending.cacheReadTokens !== undefined || - pending.cacheCreationTokens !== undefined - ); -} - -function buildRecord(vendorId: string, pending: PendingTurn): LocalCostCandidateRecord { - return { - kind: "request", - vendor_id: vendorId, - vendor_field: VENDOR_FIELD, - turn_id: pending.turnId, - turn_field: TURN_FIELD, - ...(pending.model !== undefined ? { model: pending.model } : {}), - ...(pending.effort !== undefined ? { effort: pending.effort } : {}), - ...(pending.at !== undefined ? { event_timestamp: pending.at } : {}), - ...(pending.inputTokens !== undefined ? { input_tokens: pending.inputTokens } : {}), - ...(pending.outputTokens !== undefined ? { output_tokens: pending.outputTokens } : {}), - ...(pending.cacheReadTokens !== undefined - ? { cache_read_tokens: pending.cacheReadTokens } - : {}), - ...(pending.cacheCreationTokens !== undefined - ? { cache_creation_tokens: pending.cacheCreationTokens } - : {}), - }; -} - -/** One record per turn, never per line. A turn is closed by the *next* `turn_context`, and a - * rollout has no line saying the session is finished, so the final flush cannot tell an ended - * session from a running one: it emits what the counters sum to so far, and whether a later - * read's record for the same `turn_id` corrects it is decided downstream. */ -class CodexRolloutAccumulator implements TranscriptLineAccumulator { - private vendorId: string | undefined; - private pending: PendingTurn | undefined; - /** The cumulative last counted, so a re-emitted final `token_count` is not added twice. */ - private lastCumulative: string | undefined; - private readonly records: LocalCostCandidateRecord[] = []; - - push(line: string): void { - const parsed = parseLine(line); - if (!parsed?.payload) return; - if (parsed.type === "session_meta") this.vendorId = asString(parsed.payload.id); - else if (parsed.type === "turn_context") this.startNewTurn(parsed.payload, parsed.timestamp); - else if (parsed.type === "event_msg" && parsed.payload.type === "token_count") { - this.applyTokenCount( - parsed.payload.info?.last_token_usage, - parsed.payload.info?.total_token_usage - ); - } - } - - build(): readonly LocalCostCandidateRecord[] { - this.flush(); - return this.records; - } - - private startNewTurn(payload: NonNullable, timestamp: unknown): void { - this.flush(); - this.pending = startTurn(payload, asString(timestamp)) ?? undefined; - } - - private applyTokenCount( - usage: CodexTokenUsage | undefined, - cumulative: CodexTokenUsage | undefined - ): void { - if (!this.pending || !usage) return; - const key = cumulativeKey(cumulative); - if (key !== null && key === this.lastCumulative) return; - if (key !== null) this.lastCumulative = key; - addUsage(this.pending, usage); - } - - private flush(): void { - if (this.pending && this.vendorId !== undefined && hasCounters(this.pending)) { - this.records.push(buildRecord(this.vendorId, this.pending)); - } - this.pending = undefined; - } -} - -export function createCodexRolloutAccumulator(): TranscriptLineAccumulator { - return new CodexRolloutAccumulator(); -} - -/** For a fixture-driven test to target directly: the adapter streams the accumulator one line - * at a time, so a large rollout is never held whole in memory. */ -export function mapCodexRolloutToSinkRecords(content: string): readonly LocalCostCandidateRecord[] { - const accumulator = createCodexRolloutAccumulator(); - for (const line of content.split("\n")) accumulator.push(line); - return accumulator.build(); -} diff --git a/cli/src/contexts/telemetry/domain/formats/commit-session-trailer.ts b/cli/src/contexts/telemetry/domain/formats/commit-session-trailer.ts deleted file mode 100644 index 8ba70a9d5..000000000 --- a/cli/src/contexts/telemetry/domain/formats/commit-session-trailer.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { HookManager } from "../telemetry-setup.js"; - -/** The one link between a commit and the session that produced it: every other hop exists, so a - * trailer carrying whatever `session-anchor.ts` resolves — never a second identifier minted here - * — makes "this commit cost X" answerable. Both hosts' anchors equal the `vendor_id` records join - * on, except inside a Codex subagent, where the anchor may name the delegating thread instead. */ - -/** Capitalised the way `Co-authored-by` and `Signed-off-by` are: `git interpret-trailers` - * matches a token case-insensitively but writes back what it was given, and a history spelling - * one trailer three ways is one nobody can grep. */ -export const SESSION_TRAILER_TOKEN = "AIDD-Session-Id"; - -/** The delegate lives beside the hook that calls it rather than inside it, so a repository - * already running a `prepare-commit-msg` hook keeps it and gains one line calling this. */ -export const SESSION_TRAILER_DELEGATE_FILE = "aidd-session-trailer.sh"; - -/** What a `prepare-commit-msg` written from scratch starts with, and — read back — the one - * line that does not count as somebody else's content. One spelling for both sides, or a - * freshly installed hook reports as somebody else's. */ -export const SESSION_TRAILER_HOOK_HEADER = "#!/bin/sh"; - -/** Both the line appended to `prepare-commit-msg` and the marker read back to detect it, so the - * two sides cannot spell it differently; `"$@"` forwards git's own arguments. Separators are - * forced to `/`: the `sh` Git for Windows ships treats a backslash inside double quotes as an - * ordinary character, so a path Node resolved there would arrive literally and name nothing. */ -export function sessionTrailerHookLine(delegatePath: string): string { - return `sh "${delegatePath.replace(/\\/gu, "/")}" "$@"`; -} - -/** Resolved at run time rather than baked in at write time, so a hand-added line survives - * being carried into a checkout living somewhere else. The `[ -f "$delegate" ]` guard keeps a - * later `aidd telemetry off`, which deletes only the script, from leaving every commit calling - * a file that no longer exists. */ -function delegateLookup(delegateFile: string): string { - return `delegate="$(git rev-parse --git-common-dir)/hooks/${delegateFile}"`; -} - -/** `{1}` and `{2}` are lefthook's own placeholders for the message-file and source arguments - * git passes a `prepare-commit-msg` hook — the counterpart of `"$@"` in a plain shell hook. */ -export function sessionTrailerLefthookJob(delegateFile: string): string { - return `prepare-commit-msg: - commands: - aidd-session-trailer: - run: | - ${delegateLookup(delegateFile)} - if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi`; -} - -/** Husky's own hook is a plain shell script, so `"$@"` forwards git's arguments the way it - * does for a hook this CLI owns outright. */ -export function sessionTrailerHuskyLine(delegateFile: string): string { - return `${delegateLookup(delegateFile)} -[ -f "$delegate" ] && sh "$delegate" "$@"`; -} - -/** Lefthook's snippet goes under a top-level `prepare-commit-msg:` key, so "add this job to - * lefthook.yml" followed literally would give a file that already has one a duplicate YAML - * key. Husky's hook has no such structure, and a line is still a line there. */ -export function sessionTrailerManagerInstruction(manager: HookManager, targetFile: string): string { - return manager === "lefthook" - ? `add this command under \`prepare-commit-msg:\` in ${targetFile}` - : `add this line to ${targetFile}`; -} - -/** The one place both `telemetry on` and `telemetry check` read this from, so the file named - * and the snippet printed cannot drift apart. */ -export function sessionTrailerManagerSnippet( - manager: HookManager, - delegateFile: string -): { readonly manager: HookManager; readonly targetFile: string; readonly snippet: string } { - if (manager === "lefthook") { - return { - manager, - targetFile: "lefthook.yml", - snippet: sessionTrailerLefthookJob(delegateFile), - }; - } - return { - manager, - targetFile: ".husky/prepare-commit-msg", - snippet: sessionTrailerHuskyLine(delegateFile), - }; -} - -/** POSIX `sh`, no Node, no dependency on this CLI still being installed. A hook that fails is a - * commit that fails, so every path ends in `exit 0`. Variable precedence is `session-anchor.ts`'s; - * neither set means no AI session made the commit and it gets no trailer. No commit is skipped by - * `message_source`: a merge a session resolved by hand cost as much as any other change. */ -export function sessionTrailerDelegateScript(): string { - return `#!/bin/sh -# Installed by \`aidd telemetry on\`, removed by \`aidd telemetry off\`. -# -# Names the AI session that authored this commit, so what a session cost can be read -# per commit. Writes nothing when no session made the commit. -set -u - -message_file="\${1:-}" - -[ -n "$message_file" ] || exit 0 - -session_id="\${CODEX_THREAD_ID:-\${CLAUDE_CODE_SESSION_ID:-}}" -[ -n "$session_id" ] || exit 0 - -# --if-exists doNothing keeps an amend, or a second run of this hook, from writing it twice. -git interpret-trailers --in-place --if-exists doNothing \\ - --trailer "${SESSION_TRAILER_TOKEN}=$session_id" "$message_file" || exit 0 - -exit 0 -`; -} diff --git a/cli/src/contexts/telemetry/domain/formats/copilot-events.ts b/cli/src/contexts/telemetry/domain/formats/copilot-events.ts deleted file mode 100644 index 4bad2e571..000000000 --- a/cli/src/contexts/telemetry/domain/formats/copilot-events.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { LocalCostCandidateRecord } from "../ports/session-cost-reader.js"; - -// `session.shutdown` fires once at the end of a session, never per turn, and its `tokenDetails` -// is exclusive of the cache figures where the sibling `usage` object is inclusive of them — -// which is why this reader takes the first. `requests.cost` and `totalPremiumRequests` are a -// count times a per-model multiplier, invariant to consumption, so neither is read as `cost_usd`; -// no `model` is stamped either, `currentModel` naming only the last one a session used. -const VENDOR_FIELD = "sessionId"; -const TURN_FIELD = "id"; - -interface CopilotTokenCount { - readonly tokenCount?: unknown; -} - -interface CopilotShutdownData { - readonly tokenDetails?: { - readonly input?: CopilotTokenCount; - readonly output?: CopilotTokenCount; - readonly cache_read?: CopilotTokenCount; - readonly cache_write?: CopilotTokenCount; - }; -} - -interface CopilotEventLine { - readonly type?: unknown; - readonly id?: unknown; - readonly timestamp?: unknown; - readonly data?: CopilotShutdownData; -} - -interface CopilotCounters { - readonly input_tokens: number; - readonly output_tokens: number; - readonly cache_read_tokens: number; - readonly cache_creation_tokens: number; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -/** All four or none: a shape this file has not been taught — a renamed field, an empty - * `tokenDetails` — yields no record rather than one silently missing every counter. */ -function readCounters(details: CopilotShutdownData["tokenDetails"]): CopilotCounters | null { - const input = asNumber(details?.input?.tokenCount); - const output = asNumber(details?.output?.tokenCount); - const cacheRead = asNumber(details?.cache_read?.tokenCount); - const cacheWrite = asNumber(details?.cache_write?.tokenCount); - if (input === undefined || output === undefined) return null; - if (cacheRead === undefined || cacheWrite === undefined) return null; - return { - input_tokens: input, - output_tokens: output, - cache_read_tokens: cacheRead, - cache_creation_tokens: cacheWrite, - }; -} - -function buildRecord( - line: CopilotEventLine, - vendorId: string, - counters: CopilotCounters -): LocalCostCandidateRecord { - const turnId = asString(line.id); - const timestamp = asString(line.timestamp); - return { - kind: "session", - vendor_id: vendorId, - vendor_field: VENDOR_FIELD, - ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), - ...(timestamp !== undefined ? { event_timestamp: timestamp } : {}), - ...counters, - }; -} - -function parseLine(line: string): CopilotEventLine | null { - const trimmed = line.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as CopilotEventLine; - } catch { - return null; - } -} - -/** One record at most: no per-request figure exists on this tool's file at all, and a session - * that never shut down or shut down unbilled yields nothing rather than a record of zeros. - * `vendorId` is the caller's own — the directory already names the session, where a truncated - * copy of the file would not — and this stays pure; the adapter is what opens a file. */ -export function mapCopilotEventsToSinkRecords( - content: string, - vendorId: string -): readonly LocalCostCandidateRecord[] { - for (const raw of content.split("\n")) { - const parsed = parseLine(raw); - if (parsed?.type !== "session.shutdown") continue; - const counters = readCounters(parsed.data?.tokenDetails); - if (counters === null) continue; - return [buildRecord(parsed, vendorId, counters)]; - } - return []; -} diff --git a/cli/src/contexts/telemetry/domain/formats/opencode-export.ts b/cli/src/contexts/telemetry/domain/formats/opencode-export.ts deleted file mode 100644 index a96d770b3..000000000 --- a/cli/src/contexts/telemetry/domain/formats/opencode-export.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { LocalCostCandidateRecord } from "../ports/session-cost-reader.js"; - -// The counters are disjoint — `total == input + output + reasoning + cache.read + cache.write` -// held for every provider captured, with `input` shrinking as `cache.read` climbed — so nothing -// cached is subtracted here. `info.cost` is never read: it reads `0` throughout and its -// denomination is unestablished, a figure whose meaning is unknown being worse than an absent -// one. `info.providerID` is never read either, the stored record carrying no provider field, so -// the residual limit lives in `profiles/opencode/profile.ts`'s `telemetryLocalRead.limitation`. -const VENDOR_FIELD = "sessionID"; -const TURN_FIELD = "id"; - -interface OpencodeTokenCounts { - readonly total?: unknown; - readonly input?: unknown; - readonly output?: unknown; - readonly cache?: { readonly read?: unknown; readonly write?: unknown }; -} - -interface OpencodeMessageInfo { - readonly id?: unknown; - readonly modelID?: unknown; - readonly tokens?: OpencodeTokenCounts; - readonly time?: { readonly created?: unknown; readonly completed?: unknown }; -} - -interface OpencodeExportPayload { - readonly messages?: readonly { readonly info?: OpencodeMessageInfo }[]; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -// Epoch milliseconds. `time.created`, not `time.completed`: completed is absent on some -// counted messages, and a field meaning "started" on one record and "finished" on the next is -// worse than one that always means the same thing. -function isoFromEpochMillis(value: unknown): string | undefined { - const millis = asNumber(value); - if (millis === undefined || millis <= 0) return undefined; - const at = new Date(millis); - return Number.isNaN(at.getTime()) ? undefined : at.toISOString(); -} - -function buildIdentity( - info: OpencodeMessageInfo, - sessionId: string -): Pick { - const turnId = asString(info.id); - return { - vendor_id: sessionId, - vendor_field: VENDOR_FIELD, - ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), - }; -} - -// `cache.read`/`cache.write` are the same quantities the other tools already call -// cache-read and cache-creation — mapped onto those field names, not OpenCode's own. -function buildCounters( - tokens: OpencodeTokenCounts -): Pick< - LocalCostCandidateRecord, - "input_tokens" | "output_tokens" | "cache_read_tokens" | "cache_creation_tokens" -> { - const input = asNumber(tokens.input); - const output = asNumber(tokens.output); - const cacheRead = asNumber(tokens.cache?.read); - const cacheWrite = asNumber(tokens.cache?.write); - return { - ...(input !== undefined ? { input_tokens: input } : {}), - ...(output !== undefined ? { output_tokens: output } : {}), - ...(cacheRead !== undefined ? { cache_read_tokens: cacheRead } : {}), - ...(cacheWrite !== undefined ? { cache_creation_tokens: cacheWrite } : {}), - }; -} - -// A message OpenCode created but never billed — an interrupted response — carries `tokens` -// with no `total` key at all, and is not reliably given a `finish` either, so `total`'s absence -// is the signal. A message that completed with genuinely zero usage still carries `total: 0` -// and still yields a record: that is an observation, not a call that never happened. -function wasBilled(tokens: OpencodeTokenCounts): boolean { - return asNumber(tokens.total) !== undefined; -} - -function buildRecord( - info: OpencodeMessageInfo, - sessionId: string -): LocalCostCandidateRecord | null { - if (info.tokens === undefined) return null; - if (!wasBilled(info.tokens)) return null; - const model = asString(info.modelID); - const at = isoFromEpochMillis(info.time?.created); - return { - kind: "request", - ...buildIdentity(info, sessionId), - ...(model !== undefined ? { model } : {}), - ...(at !== undefined ? { event_timestamp: at } : {}), - ...buildCounters(info.tokens), - }; -} - -/** A message with no `info.tokens` — every user turn, and any turn OpenCode never measured — - * yields no record rather than an invented zero, and so does one never billed (`wasBilled`), - * which would otherwise inflate the request count. `sessionId` is trusted as given, matching - * every other local reader's contract. */ -export function mapOpencodeExportToSinkRecords( - payload: unknown, - sessionId: string -): readonly LocalCostCandidateRecord[] { - const messages = (payload as OpencodeExportPayload)?.messages ?? []; - const records: LocalCostCandidateRecord[] = []; - for (const message of messages) { - const record = buildRecord(message?.info ?? {}, sessionId); - if (record) records.push(record); - } - return records; -} diff --git a/cli/src/contexts/telemetry/domain/journal-intervals.ts b/cli/src/contexts/telemetry/domain/journal-intervals.ts deleted file mode 100644 index a3334f94b..000000000 --- a/cli/src/contexts/telemetry/domain/journal-intervals.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** The one walk that turns run-journal lines into closed intervals, shared by - * `task-attribution.ts` and `flow-attribution.ts`: they differ only in opener, closer and - * payload. */ - -/** One boundary-like value, paired with the millisecond moment its own `at` parses to. */ -export interface TimedBoundary { - readonly atMs: number; - readonly boundary: T; -} - -/** Every `at`-bearing value, timed and sorted, dropping one whose own `at` cannot be parsed. - * Left in, it would occupy a list index while showing nothing, widening the interval before it. */ -export function timed( - boundaries: readonly T[] -): readonly TimedBoundary[] { - return boundaries - .map((boundary) => ({ atMs: Date.parse(boundary.at), boundary })) - .filter(({ atMs }) => !Number.isNaN(atMs)) - .sort((left, right) => left.atMs - right.atMs); -} - -/** The journal's own last recorded moment, capped at `periodEndMs` when one is given, so a - * clock-skewed far-future moment never widens an unclosed interval past what a report could - * place a record in. `timed()` refuses only what it cannot parse; this refuses the absurd. */ -function cappedLastMoment(witnessedLastMs: number, periodEndMs: number | undefined): number { - return periodEndMs === undefined ? witnessedLastMs : Math.min(witnessedLastMs, periodEndMs); -} - -/** What ended an interval: a moment the journal witnessed, or the `journal-end` cap standing in - * for one it never did. The distinction is a caller's to act on - a capped end is a bound, not - * a measured extent. Two values and not three: no caller separates "closed by its own - * `step_end`" from "closed by a later opener", both being moments the journal witnessed. */ -export type IntervalClosure = "boundary" | "journal-end"; - -/** The two facts every closed interval this module builds needs — `path` (`TaskInterval`) or - * `skill` (`FlowInterval`) rides beside these, never inside this shape itself. */ -export interface ClosedInterval { - readonly startMs: number; - readonly endMs: number; -} - -/** Whether a record's own moment falls inside one of `intervals` - never true for a record with - * no moment, or one earlier than every interval, which keeps an interval from being read - * backward onto work that happened before it ever opened. */ -export function momentFallsWithin( - intervals: readonly ClosedInterval[], - momentIso: string | undefined -): boolean { - if (momentIso === undefined) return false; - const momentMs = Date.parse(momentIso); - if (Number.isNaN(momentMs)) return false; - return intervals.some((interval) => momentMs >= interval.startMs && momentMs < interval.endMs); -} - -/** Journal lines in, closed intervals out - the one walk `buildTaskIntervals` and - * `buildFlowIntervals` both run. `isCloser` is asked about the opener as well as the candidate, - * and an interval ends at the first later boundary either predicate accepts, never at the first - * `isCloser`. `toInterval` may answer `null` to close an interval without emitting a row. */ -export function buildClosedIntervals< - TBoundary extends { readonly at: string }, - TOpener extends TBoundary, - TInterval, ->( - boundaryLike: readonly TBoundary[], - periodEndMs: number | undefined, - isOpener: (boundary: TBoundary) => boundary is TOpener, - isCloser: (boundary: TBoundary, opener: TOpener) => boolean, - toInterval: ( - opener: TOpener, - startMs: number, - endMs: number, - closedBy: IntervalClosure - ) => TInterval | null -): readonly TInterval[] { - const everyWitnessedMoment = timed(boundaryLike); - // Not one readable moment in the whole journal: no interval either. Returning here is also - // what makes `lastMs` a moment rather than a maybe-moment for the rest of this function. - if (everyWitnessedMoment.length === 0) return []; - const lastMs = cappedLastMoment( - everyWitnessedMoment[everyWitnessedMoment.length - 1].atMs, - periodEndMs - ); - const intervals: TInterval[] = []; - for (let i = 0; i < everyWitnessedMoment.length; i++) { - const { atMs: startMs, boundary } = everyWitnessedMoment[i]; - if (!isOpener(boundary)) continue; - const closerMs = firstCloserAfter(everyWitnessedMoment, i, boundary, isOpener, isCloser); - const interval = - closerMs === undefined - ? toInterval(boundary, startMs, lastMs, "journal-end") - : toInterval(boundary, startMs, closerMs, "boundary"); - if (interval !== null) intervals.push(interval); - } - return intervals; -} - -/** The moment the interval opened at `from` ends, or `undefined` when nothing closes it. - * Scanned forward rather than pre-filtered because `isCloser` is asked about the pair: a - * `step_end` closes the flow whose skill it names and no other. */ -function firstCloserAfter( - everyWitnessedMoment: readonly TimedBoundary[], - from: number, - opener: TOpener, - isOpener: (boundary: TBoundary) => boundary is TOpener, - isCloser: (boundary: TBoundary, opener: TOpener) => boolean -): number | undefined { - for (let i = from + 1; i < everyWitnessedMoment.length; i++) { - const { atMs, boundary } = everyWitnessedMoment[i]; - if (isOpener(boundary) || isCloser(boundary, opener)) return atMs; - } - return undefined; -} diff --git a/cli/src/contexts/telemetry/domain/person-resolution.ts b/cli/src/contexts/telemetry/domain/person-resolution.ts deleted file mode 100644 index 1267486f8..000000000 --- a/cli/src/contexts/telemetry/domain/person-resolution.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { PersonIdentity } from "./ports/person-identity-reader.js"; - -/** How a record's person was resolved. `person_id` is stamped at store time, so its absence - * tracks the identity, not the work; `"this-machine"` holds only while the sink has one writer. - * - * - `"mapped"` — this machine's own `personId`, or one of the identifiers in `alsoMe`. - * - `"unresolved"` — a real identifier nobody's identity covers. - * - `"this-machine"` — the record carried no identifier and this machine declared an identity. - * - `"none"` — no identifier **and** no identity: nobody opted in, which unresolved never says. */ -export type PersonResolution = "mapped" | "unresolved" | "none" | "this-machine"; - -/** What resolving one raw identifier against an identity answers. `identities` always carries - * what produced the row — the canonical `personId` when mapped, the raw identifier when - * unresolved — so a caller shows a row's evidence without re-reading the identity. */ -export interface ResolvedPerson { - readonly resolution: PersonResolution; - readonly personId?: string; - readonly displayName?: string; - readonly identities: readonly string[]; -} - -function matches(identity: PersonIdentity, rawId: string): boolean { - return identity.personId === rawId || identity.alsoMe.includes(rawId); -} - -/** One identity, as the person a row names and the evidence behind it — shared by the two routes - * that end at this machine's own person, so they cannot describe them differently. `alsoMe` - * cannot contain `personId`: `PersonIdentityUseCase.link` refuses it, so no check belongs here. */ -function claimedBy(identity: PersonIdentity): Omit { - return { - personId: identity.personId, - ...(identity.displayName === undefined ? {} : { displayName: identity.displayName }), - identities: [identity.personId, ...identity.alsoMe], - }; -} - -/** Resolves one raw identifier against this machine's own identity. A `null` `identity` resolves - * every identifier as `unresolved`, exactly as one that does not cover it would: a report shows - * both gaps the same way. An empty `rawId` answers `"this-machine"` or `"none"`, and never - * overrules an identifier the record did carry. */ -export function resolvePerson( - identity: PersonIdentity | null, - rawId: string | undefined -): ResolvedPerson { - if (rawId === undefined || rawId === "") { - return identity === null - ? { resolution: "none", identities: [] } - : { ...claimedBy(identity), resolution: "this-machine" }; - } - if (identity === null || !matches(identity, rawId)) { - return { resolution: "unresolved", identities: [rawId] }; - } - return { ...claimedBy(identity), resolution: "mapped" }; -} - -/** `identity` with `value` added to `alsoMe`, deduplicated, and never the person's own - * `personId` — the one place this rule is written, so the adapter and its test double share it. - * A person's own identifier is not an identifier *added onto* them. */ -export function withAlsoMeAdded(identity: PersonIdentity, value: string): PersonIdentity { - return identity.alsoMe.includes(value) || value === identity.personId - ? identity - : { ...identity, alsoMe: [...identity.alsoMe, value] }; -} - -/** `current` re-anchored on `personId`, taken from another machine — keeping what was declared, - * minus `personId` itself. That subtraction is why this exists instead of a literal in the - * adapter: `link X` then `use X` is an ordinary sequence, and without it the person's own - * identifier reads as one added onto themselves. */ -export function withPersonIdAdopted( - current: PersonIdentity | null, - personId: string -): PersonIdentity { - return { - personId, - origin: "adopted", - alsoMe: (current?.alsoMe ?? []).filter((raw) => raw !== personId), - ...(current?.displayName === undefined ? {} : { displayName: current.displayName }), - }; -} - -/** `identity` with `value` withdrawn from `alsoMe`, wherever it is — an identifier not - * listed leaves `alsoMe` unchanged rather than failing. */ -export function withAlsoMeRemoved(identity: PersonIdentity, value: string): PersonIdentity { - return { ...identity, alsoMe: identity.alsoMe.filter((raw) => raw !== value) }; -} diff --git a/cli/src/contexts/telemetry/domain/ports/hook-trust-reader.ts b/cli/src/contexts/telemetry/domain/ports/hook-trust-reader.ts deleted file mode 100644 index fafd6edec..000000000 --- a/cli/src/contexts/telemetry/domain/ports/hook-trust-reader.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { TelemetryCodexHookTrust } from "../telemetry-claim.js"; - -/** - * Whether Codex trusts this plugin's hook, keyed in `~/.codex/config.toml` exactly on the - * event name: a hook approved under a renamed event inherits no approval. `readable: false` - * covers every fs failure and licenses no guess at trust in either direction. - */ -export interface HookTrustReader { - read(): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/ports/ignore-entries.ts b/cli/src/contexts/telemetry/domain/ports/ignore-entries.ts deleted file mode 100644 index 927b010bf..000000000 --- a/cli/src/contexts/telemetry/domain/ports/ignore-entries.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Adding lines to whatever this project uses to keep files out of version control. A port - * telemetry owns rather than a call into the context that manages project files: the need is - * "these entries must be ignored", not "run that context's use case". - */ -export interface IgnoreEntries { - /** Adds every entry not already present, answering whether anything was added. */ - execute(projectRoot: string, entries: string[]): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/ports/installed-plugins-reader.ts b/cli/src/contexts/telemetry/domain/ports/installed-plugins-reader.ts deleted file mode 100644 index b4fed7257..000000000 --- a/cli/src/contexts/telemetry/domain/ports/installed-plugins-reader.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { AiToolId } from "../../../../kernel/tool.js"; - -/** One plugin, as measurement needs it: a name to report and the marketplace a host's own - * registry is keyed by. Nothing else is a fact `telemetry check` states. */ -export interface InstalledPluginRef { - readonly name: string; - readonly marketplace: string | undefined; -} - -/** - * What this project recorded as installed, per tool. A port telemetry owns, so measurement - * never reaches into the context that keeps the record. - */ -export interface InstalledPluginsReader { - /** The file the record lives in, so an unreadable one can be reported by name. */ - readonly path: string; - - /** - * Every plugin recorded, keyed by the tool it was installed for. `null` when this project - * has no record at all — which is not an error, only a project nothing was installed into. - * - * Throws when a record exists and cannot be read: `telemetry check` is the command a person - * runs when something is wrong, so a damaged file must say so rather than read as empty. - */ - read(): Promise | null>; -} diff --git a/cli/src/contexts/telemetry/domain/ports/person-identity-reader.ts b/cli/src/contexts/telemetry/domain/ports/person-identity-reader.ts deleted file mode 100644 index 8b2db4c86..000000000 --- a/cli/src/contexts/telemetry/domain/ports/person-identity-reader.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** What a person chose to attach to this machine's records — never derived from a git - * author, an email or a hostname. One file, one person: nothing here can express a second. - * `origin` says how the identity came to be, `"adopted"` being a declaration the tool cannot - * check, which is why no third value is reserved for a verification nothing can perform. - * `alsoMe` is required and reads back empty rather than absent — only `displayName` uses - * absence for "not set". */ -export interface PersonIdentity { - readonly personId: string; - readonly origin: "minted" | "adopted"; - readonly alsoMe: readonly string[]; - readonly displayName?: string; -} - -/** - * The identifier this machine's own user chose, or `null` when nobody did — a missing file, a - * damaged one and a default installation all answer the same way. Never throws: an unreadable - * identity file costs the identity, not the local-read sweep around it. Reads only the OS - * user's own profile, never `AIDD_USER_CONFIG_DIR` and never a project's `.aidd/config.json`, - * since a repository or a CI job can set those and this choice is not theirs to make. - */ -export interface PersonIdentityReader { - read(): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/ports/person-identity-store.ts b/cli/src/contexts/telemetry/domain/ports/person-identity-store.ts deleted file mode 100644 index 6dd2dc8f3..000000000 --- a/cli/src/contexts/telemetry/domain/ports/person-identity-store.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { PersonIdentity, PersonIdentityReader } from "./person-identity-reader.js"; - -/** - * What the `aidd telemetry identity` verbs need beyond `PersonIdentityReader.read()`, which - * never throws so a damaged identity file cannot cost a local-read sweep its figures. A - * person asking about their own state is the opposite question, so `readStrict()` throws - * there instead of folding into "nobody chose". Resolved from the OS user's own profile only, - * like the reader. - */ -export interface PersonIdentityStore extends PersonIdentityReader { - /** Where the identity file lives, for messages that name it. */ - readonly filePath: string; - - /** Like `read()`, but surfaces a damaged or unreadable file as a throw instead of `null`. */ - readStrict(): Promise; - - /** Generates a fresh identifier and writes it unconditionally, `origin: "minted"` — whether - * one is needed at all is the caller's call, never this store's. */ - mint(): Promise; - - /** Writes `personId` as this machine's own identifier, `origin: "adopted"`, keeping the - * `alsoMe` and `displayName` already declared. Whether adopting is the right move at all is - * the caller's call; this store only writes what it is told. */ - adopt(personId: string): Promise; - - /** Adds `identity` to the current identity's `alsoMe`, unconditionally — the caller - * decides whether a person exists to add onto at all; this store assumes one does. - * A no-op, not a duplicate, when `identity` is already listed. */ - addAlsoMe(identity: string): Promise; - - /** Withdraws `identity` from the current identity's `alsoMe`, wherever it is. An - * identifier not listed is nothing to remove, never a failure. */ - removeAlsoMe(identity: string): Promise; - - /** Writes `identity` back with `displayName` attached, replacing any previous one. */ - setDisplayName(identity: PersonIdentity, displayName: string): Promise; - - /** Removes the identity file at `path`, answering whether one was actually there — a no-op, - * not a failure, when there was none. `path` is never resolved here: the caller supplies - * the exact value a person was already shown, so a removal can never reach a file the - * preview never named. Answers from the filesystem rather than from a parse, since a file - * holding an empty `person_id` parses as "nobody chose" while still existing on disk. */ - forget(path: string): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts b/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts deleted file mode 100644 index d261d61bf..000000000 --- a/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** One `step_start` line from a session's run journal: a step's own start and the skill - * recorded for it. No end is carried — no tool measured so far exposes when a skill's work - * finishes, so an interval's end is the reader's derivation, not a fact on this line. */ -export interface RunJournalStepStart { - readonly type: "step_start"; - readonly at: string; - readonly skill: string; - /** The host's own identifier for the prompt this step opened under, where it hands one to a - * hook. Named `turn_id` on the line, but it is a prompt: several steps opened under one - * share it. Matched against a record's `prompt_id` it attributes a step exactly, the only - * reading that survives two tasks advancing at once. Absent for a host that hands its hooks - * no such identifier. */ - readonly turn_id?: string; -} - -/** One `turn_end` line: closes whatever step was open, even where no further step opens - * before the turn itself ends. */ -export interface RunJournalTurnEnd { - readonly type: "turn_end"; - readonly at: string; -} - -/** One `step_end` line: the moment a skill said its own work was over. No host emits this, - * which is why the skill declares it. It closes only its own skill's open interval: closing - * "whatever is open" would close the wrong one the moment a skill invokes another, and an end - * naming a skill this session never started closes nothing rather than truncating. */ -export interface RunJournalStepEnd { - readonly type: "step_end"; - readonly at: string; - readonly skill: string; -} - -export type RunJournalBoundary = RunJournalStepStart | RunJournalTurnEnd | RunJournalStepEnd; - -/** The `session_start` line: the one line naming what a session was. `tool` holds the journal - * hook's own host identifier ("claude-code", "codex", "copilot", "cursor"), which is not an - * `AiToolId` — `journalHostToAiToolId` is the only place the two are related, and it reads a - * declaration rather than a table. */ -export interface RunJournalSessionStart { - readonly type: "session_start"; - readonly at: string; - /** The schema the hook stamped this journal with, absent for one written before the field - * existed. Read and carried, never derived: a reader inferring a schema from the shapes it - * happens to recognise is the silent misreading this field exists to prevent. */ - readonly schema_version?: number; - readonly run_id: string; - readonly tool: string; - readonly vendor_id: string; - readonly project_id?: string; - /** The git remote this session's repository resolved to, absent for one with none. Carried - * beside `project_id` rather than replacing it. */ - readonly project_remote?: string; - /** Git's own name for the linked worktree this session ran in, so two worktrees of one - * repository are distinguishable. Absent — never `""` — for a plain checkout. */ - readonly worktree_id?: string; - /** The repository those worktrees share, named from `--git-common-dir`. Recorded beside - * `worktree_id` rather than left to `project_id`, which falls back to the worktree's own - * directory name when a clone has no remote. Absent whenever `worktree_id` is. */ - readonly worktree_repo_id?: string; - /** The plugin's own version at the moment this line was written - never the framework's, - * and never the CLI's, which stamps only the record it stores. Absent reads as an unknown - * version, never as a default or a guess. */ - readonly plugin_version?: string; -} - -/** A `file_written` line: a repository-relative, "/"-separated path a session wrote inside a - * task folder, and when. Carries no task identity — the hook refuses to store a derivation as - * a fact, so deriving the task is the reader's job. */ -export interface RunJournalFileWritten { - readonly type: "file_written"; - readonly at: string; - readonly path: string; -} - -/** A `task_declared` line: a tool call named a file under a task folder, so this session is on - * that task from here on — told rather than inferred. Carries no task identity for the reason - * `file_written` does not. Deliberately outside `RunJournalBoundary`: an interval walk merges - * it in as a moment the journal witnessed, never as a boundary that ends something, and the - * type keeps the two apart so a later reader cannot confuse them. */ -export interface RunJournalTaskDeclared { - readonly type: "task_declared"; - readonly at: string; - readonly path: string; -} - -/** What the journal side promises a reader, for one session's run file, in file order — lines - * read, nothing derived. Deriving intervals from `boundaries`, or a task from `filesWritten`, - * is the reader's job. `session` is optional because a file whose first line is torn is still - * worth its boundaries. */ -export interface RunJournal { - readonly boundaries: readonly RunJournalBoundary[]; - readonly session?: RunJournalSessionStart; - readonly filesWritten: readonly RunJournalFileWritten[]; - readonly taskDeclarations: readonly RunJournalTaskDeclared[]; -} - -/** - * The boundaries recorded for one session, or `null` when nothing can be said about it — no - * run file, an unreadable runs directory, telemetry never enabled. Never throws: a missing, - * unreadable or truncated journal costs attribution, not the read itself. Read-only on - * purpose, so a caller that only reads cannot reach the verb that removes; one adapter - * implements this and `RunJournalStore` below both. - */ -export interface RunJournalReader { - read(sessionId: string): Promise; - /** Every session the journal holds, for a caller with no identifier to ask about. Filtering - * to a period is the caller's, from each journal's own `session.at`: the run file's name - * carries no date. Never throws; an unreadable runs directory answers an empty list. */ - list(): Promise; - /** Every run file's own name, directly from the directory — never opened, never parsed. - * Distinct from `list()`, which can silently drop a file it cannot parse: a caller counting - * what removing this journal would touch needs a name a damaged file still has. Never - * throws, the same failure direction as `list()`. */ - listRunFiles(): Promise; - /** The schema stated by every journal this reader refused to read, one entry per file. - * `list()` drops such a journal outright, and a caller shown only that emptiness would - * report a torn file about one whose header it parsed perfectly well. Empty is the ordinary - * answer. Never throws, like everything else here. */ - listForeignSchemas(): Promise; -} - -/** - * What removing a journal needs beyond a plain read — extends `RunJournalReader` rather than - * sitting beside it, so the one adapter that resolves the runs directory implements one port. - */ -export interface RunJournalStore extends RunJournalReader { - /** Where this project's run journal lives — the same directory `read`/`list` and - * `listRunFiles` resolve, exposed so a caller naming the location never re-derives the - * `AIDD_RUNS_DIR`-aware resolution itself. `deleteRunFile` below is handed back this value - * rather than deriving its own. */ - readonly runsDir: string; - /** Removes one run file, by the name `listRunFiles()` named it with, from `dir` — mirrors - * `TelemetrySink.deleteDayFile`. `dir` is never resolved here: the caller passes the exact - * directory a person was already shown, so a removal can never reach one the preview never - * named. `fileName` must name exactly one entry directly inside `dir`; anything else, - * including a relative walk out of it, is refused rather than deleted. A no-op, not a - * failure, when the name is already gone. */ - deleteRunFile(dir: string, fileName: string): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/ports/session-cost-reader.ts b/cli/src/contexts/telemetry/domain/ports/session-cost-reader.ts deleted file mode 100644 index 0c464bed7..000000000 --- a/cli/src/contexts/telemetry/domain/ports/session-cost-reader.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; - -/** What a per-tool local reader returns: the stored shape minus the four fields the caller - * stamps uniformly. A reader able to set `provenance`, `tool` or `step_attribution` could - * claim to be an export it is not, name another tool, or state a derivation only the caller - * — which alone reads the run journal — can perform. A reader may still set `step` and - * `step_plugin`: that presence *is* the tool-stated fact the caller resolves - * `step_attribution: "tool-stated"` from. */ -export type LocalCostCandidateRecord = Omit< - TelemetrySinkRecord, - "sink_schema_version" | "provenance" | "tool" | "step_attribution" ->; - -/** What a reader answers with. `sessionFound` separates the two silences a bare empty list - * conflates: a tool that held this session and recorded nothing billable, and one that held - * no trace of it at all. Printed as one zero, a session never found reads as free. */ -export interface LocalCostReadResult { - readonly records: readonly LocalCostCandidateRecord[]; - readonly sessionFound: boolean; -} - -/** - * Given the session identity a run-journal entry already carries, the records that tool's own - * file holds for it — nothing joined in. A tool that wrote no file answers `sessionFound: - * false` with no records rather than throwing. Every returned record's `vendor_id` equals the - * `sessionId` passed in, so a caller never resolves identity twice. `turn_id` is how a re-read - * is matched against what is already stored, and a reader whose tool carries no stable - * per-record identifier leaves it unset: an unstable synthesised key is worse than an absent - * one, since records unmatched by one are appended again rather than deduplicated. - */ -export interface SessionCostReader { - read(sessionId: string): Promise; -} - -/** - * What a per-line transcript format hands the streaming adapter: `push` for every line in file - * order, `build` once the file is exhausted. Stateful because not every tool's format maps one - * line to one record — Codex's spans a `turn_context` line and the `token_count` lines that - * follow it. Declared in the port so a domain format module can implement it without importing - * the infrastructure adapter that drives it. - */ -export interface TranscriptLineAccumulator { - push(line: string): void; - build(): readonly LocalCostCandidateRecord[]; -} diff --git a/cli/src/contexts/telemetry/domain/ports/task-backlog-reader.ts b/cli/src/contexts/telemetry/domain/ports/task-backlog-reader.ts deleted file mode 100644 index 676e5f348..000000000 --- a/cli/src/contexts/telemetry/domain/ports/task-backlog-reader.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { TaskBacklogDeclaration } from "../task-backlog-link.js"; - -/** - * One task folder's declaration, never a throw. A missing file answers `{ kind: "none" }` and - * an unparseable one `{ kind: "unreadable" }`, so a report tells "this task delivers nothing - * on the backlog" from "this task's declaration is damaged" without either costing the period - * its figures. **Never writes**, as an invariant an implementation must hold rather than - * today's behaviour: a read must never introduce the drift it was asked to measure. - */ -export interface TaskBacklogReader { - /** `taskFolderPath` is project-relative, in the shape `taskFolderPathFromIdentity` - * produces (`aidd_docs/tasks///`). Resolving it against a project root, and - * every other filesystem concern, is the adapter's job. */ - read(taskFolderPath: string): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/ports/telemetry-evidence-reader.ts b/cli/src/contexts/telemetry/domain/ports/telemetry-evidence-reader.ts deleted file mode 100644 index e96c2060f..000000000 --- a/cli/src/contexts/telemetry/domain/ports/telemetry-evidence-reader.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { TelemetryExportLeftover } from "../telemetry-export-leftover.js"; -import type { TelemetryRecorderDeclarationSetup } from "../telemetry-setup.js"; - -/** The marker the journal hook writes when a payload arrived and matched no host this build - * declares — read by name, never through the run journal reader, whose parser would leave it - * indistinguishable from a torn run file. */ -export interface TelemetryUnrecognisedPayload { - readonly at: string; -} - -/** The project's own switch file, read for what `isTelemetryEnabled`'s plain boolean cannot - * carry: the file's own `enabled` value, and whether it could be read at all. Never folds in - * the person's own refusal, which is a non-file fact the caller reads from `env` directly. */ -export interface TelemetrySwitchSetupRead { - readonly path: string; - /** The file's own `enabled` value. Meaningless when `readable` is `false` — always `false` - * there, the same "damaged reads as off" direction the gate itself takes. */ - readonly enabled: boolean; - /** `true` for a file that is absent (nothing here is a person's choice yet) or that - * parses as a valid switch. `false` only for a file that exists but could not be read or - * parsed — a damaged file, not a choice. */ - readonly readable: boolean; -} - -/** - * The evidence `aidd telemetry check` and `aidd telemetry off` need beyond the run journal, - * each tool's own local reader and Codex's hook trust, each of which has its own port: - * whether the project switch is on, the unrecognised-payload marker, and whether a settings - * file still carries a stale export configuration. A read that fails answers with the evidence - * that says so (`false`/`null`/`[]`) and never throws, so one damaged file cannot cost every - * other claim its verdict. - */ -export interface TelemetryEvidenceReader { - /** `.aidd/config.json`'s `telemetry.enabled`, read the way the hook reads it — strict - * `=== true`, so a half-written config counts as off. The person's own refusal - * (`AIDD_TELEMETRY=0`) overrides it unconditionally. */ - isTelemetryEnabled(projectRoot: string, env: NodeJS.ProcessEnv): Promise; - - readUnrecognisedPayload(projectRoot: string): Promise; - - /** Every settings file this build knows how to check that still carries a key an export - * endpoint used to write — detection only. Empty is not proof one was never configured: - * only the locations this build knows to look at are checked. */ - findLeftoverExportConfig(projectRoot: string): Promise; - - /** The project's switch file itself — see `TelemetrySwitchSetupRead` for why this is - * separate from `isTelemetryEnabled`. */ - readSwitchSetup(projectRoot: string): Promise; - - /** Whether the recorder is declared anywhere this build knows to check — the AIDD manifest - * and a tool's own settings file. Never throws: a manifest or settings file that cannot be - * parsed reads as "not declared there". */ - readRecorderDeclaration(projectRoot: string): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/ports/telemetry-sink.ts b/cli/src/contexts/telemetry/domain/ports/telemetry-sink.ts deleted file mode 100644 index 4fe721910..000000000 --- a/cli/src/contexts/telemetry/domain/ports/telemetry-sink.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; - -export interface TelemetrySinkAppendResult { - readonly filePath: string; - readonly dayFileIsNew: boolean; -} - -/** Every value a filterable field has carried, anywhere this sweep looked - not only in the - * period returned. Telling a filter naming something that never existed apart from one that - * simply had no work in this period only stays cheap because these are gathered from the same - * bytes `records` already comes from, never a second read. */ -export interface TelemetrySinkKnownValues { - readonly projects: ReadonlySet; - readonly steps: ReadonlySet; - readonly models: ReadonlySet; -} - -/** `records` are those whose `event_timestamp` falls inside the period, never those the day - * file's own name covers: a session read locally days later lands in the file for the day it - * was stored. `undated` carry no moment at all and are handed back rather than folded in, and - * `skippedLines` travels with them because a total quietly omitting lines is indistinguishable - * from a complete one. */ -export interface TelemetrySinkPeriodRead { - readonly records: readonly TelemetrySinkRecord[]; - readonly undated: readonly TelemetrySinkRecord[]; - readonly skippedLines: number; - readonly knownValues: TelemetrySinkKnownValues; -} - -/** Separate from `FileWriter`/`FileReader`: a day file is append-only for its whole life, - * never rewritten in place. `readRecordsForVendor` is the one read: a local re-read needs - * to know what is already stored for a session before it appends, or every read would - * double what came before. */ -export interface TelemetrySink { - readonly rootDir: string; - /** How `rootDir` was decided. `"user-config-dir"` is the one a caller has to react to: it - * means this person set `AIDD_USER_CONFIG_DIR`, which also relocates `auth.json`, so sharing - * this directory shares a GitHub token. Named on the port because only a command knows where - * a person is looking when it warns. */ - readonly locatedBy: "telemetry-dir" | "user-config-dir" | "default"; - ensureWritable(): Promise; - appendRecord(record: TelemetrySinkRecord, at: Date): Promise; - listDayFiles(): Promise; - /** Removes one day file, by the name `listDayFiles()` named it with, from `dir`. `dir` is - * never resolved here: the caller supplies the exact directory it already named. `fileName` - * must name exactly one entry directly inside `dir`; anything else, including a relative - * walk out of it, is refused rather than deleted. A no-op, not a failure, when the name is - * already gone. */ - deleteDayFile(dir: string, fileName: string): Promise; - /** Every stored record whose `vendor_id` matches, across every day file. A line that - * cannot be parsed is skipped rather than failing the whole scan — a torn final line - * from a concurrent write must not block reading an unrelated session. */ - readRecordsForVendor(vendorId: string): Promise; - /** Every stored record whose own moment falls in an inclusive range of UTC days, whatever - * session it belongs to. Every day file is read: a record's moment and the file it landed in - * are different days whenever a session is read after the fact. Skips a line it cannot read, - * and counts what it skipped. */ - readRecordsInPeriod(fromDay: Date, toDay: Date): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/ports/version-control.ts b/cli/src/contexts/telemetry/domain/ports/version-control.ts deleted file mode 100644 index f1bbf46bf..000000000 --- a/cli/src/contexts/telemetry/domain/ports/version-control.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { HookManager, TelemetryCommitTrailerSetup } from "../telemetry-setup.js"; - -/** What installing the delegate answered: whether a line was appended to a hook this CLI - * owns, and — when lefthook or husky owns it instead — which one, so `TelemetryOnUseCase` - * can stop promising a trailer that a hook the CLI never touched cannot deliver. */ -export interface CommitMessageDelegateInstall { - /** Whether a line was newly appended to `prepare-commit-msg`. Always `false` when - * `hookManager` is set: a hook lefthook or husky regenerates would wipe it silently. */ - readonly lineAdded: boolean; - /** Which manager owns `prepare-commit-msg` here, when one does — see `detectHookManager`. - * `undefined` is the ordinary case: this CLI still owns the hook. */ - readonly hookManager?: HookManager; - /** Whether that manager's own config already calls the delegate. Present only when - * `hookManager` is. */ - readonly managerCallsDelegate?: boolean; -} - -/** What undoing the delegate answered — the removal counterpart of - * `CommitMessageDelegateInstall`, carrying the same two manager facts so `off` can report a - * manager's own job is left behind (harmless, inert) rather than staying silent about it. */ -export interface CommitMessageDelegateRemoval { - /** Whether the line, the delegate file, or both were there to take back. `false` is the - * ordinary no-op: nothing was ever installed. */ - readonly removed: boolean; - /** Which manager owns `prepare-commit-msg` here, when one does — see `detectHookManager`. - * Present independently of `removed`: a manager's own hand-added job can outlive the - * delegate it called, and a caller needs to know that regardless of whether this run found - * anything to delete. */ - readonly hookManager?: HookManager; - /** Whether that manager's own config still calls the delegate this just removed. Present - * only when `hookManager` is. */ - readonly managerCallsDelegate?: boolean; -} - -export interface VersionControl { - /** Installs `delegateFile` beside the repository's hooks and, only when no manager owns - * `prepare-commit-msg`, adds one line to it calling the delegate. A hook lefthook or husky - * owns is regenerated from that manager's config, which would wipe an appended line - * silently, so `hookManager` tells the caller instead — the delegate script is still - * written, where the printed job resolves it at commit time. `lineAdded: false` also covers - * the no-op cases: the line is already there, or there is no repository. The hooks directory - * comes from git itself, never from an assumed `.git/hooks`: a `core.hooksPath` pointing - * elsewhere is exactly the configuration under which a hook is never run and never says so. */ - installCommitMessageDelegate( - projectRoot: string, - delegateFile: string, - script: string - ): Promise; - - /** Undoes it: drops the line from `prepare-commit-msg` and deletes the delegate, from - * whichever directory the install actually wrote to — the same manager-aware decision, so a - * removal can never look elsewhere and report nothing removed. Leaves a hook file holding - * other lines exactly as it found it, minus the one line. */ - removeCommitMessageDelegate( - projectRoot: string, - delegateFile: string - ): Promise; - /** Every tracked path matching `pathspec`, relative to `repoRoot` — empty, never a throw, - * when there is no repository or nothing matches: a project outside git still has to turn - * telemetry on quietly, so this can never be the reason that fails. */ - listTrackedFiles(repoRoot: string, pathspec: string): Promise; - - /** Whether `cwd` sits inside a git repository at all — read the way the hook reads it - * (`git rev-parse --show-toplevel`), never a throw. The journal writes nowhere without a - * repository, which is what tells that apart from a hook that fired and left no trace. */ - isRepository(cwd: string): Promise; - - /** Whether git's *history* — not the index `listTrackedFiles` reads — holds at least one - * commit touching `pathspec`. The two disagree for a file `git add`ed and never committed. - * Never a throw: no commits, no repository, or no git at all read as "no history", the same - * failure direction as `listTrackedFiles`. */ - hasHistoryFor(repoRoot: string, pathspec: string): Promise; - - /** Everything a check says about the commit trailer, gathered in one place because every - * part of it is a git question. `limit` is a count of commits rather than a date, so the - * answer costs the same on a repository of ten commits and one of a million. Never a throw: - * no repository, no commits, or no git leaves the fields that need one absent rather than - * failing the diagnostic that exists to describe them. */ - readCommitTrailerSetup( - projectRoot: string, - delegateFile: string, - trailerToken: string, - limit: number - ): Promise; -} diff --git a/cli/src/contexts/telemetry/domain/report-period.ts b/cli/src/contexts/telemetry/domain/report-period.ts deleted file mode 100644 index b7cf9c05e..000000000 --- a/cli/src/contexts/telemetry/domain/report-period.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { InvalidReportDayError, InvalidReportSpanError } from "../../../kernel/errors.js"; - -/** The two UTC days a report covers, inclusive, as they resolved. Stored beside a figure: - * "the last seven days" names two different measurements on two different days. */ -export interface ResolvedReportPeriod { - readonly fromDay: string; - readonly toDay: string; -} - -export interface ReportPeriodRequest { - readonly from?: string; - readonly to?: string; - readonly days?: string; -} - -const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; -const DAY_KEY_LENGTH = "YYYY-MM-DD".length; -const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - -/** A week: short enough that a first run answers instead of scanning a year of day files. */ -export const DEFAULT_REPORT_DAYS = 7; -const MAX_REPORT_DAYS = 3650; - -function parseDay(flag: string, value: string): string { - // Shape first, then the calendar: a well-shaped string can still name a day no month has, - // and `Date.parse` alone accepts a great deal that is not a day at all. - if (!DAY_PATTERN.test(value)) throw new InvalidReportDayError(flag, value); - const parsed = new Date(`${value}T00:00:00Z`); - if (Number.isNaN(parsed.getTime())) throw new InvalidReportDayError(flag, value); - if (dayKey(parsed) !== value) throw new InvalidReportDayError(flag, value); - return value; -} - -function parseSpan(value: string): number { - const days = Number(value); - if (!Number.isInteger(days) || days < 1 || days > MAX_REPORT_DAYS) { - throw new InvalidReportSpanError(value, MAX_REPORT_DAYS); - } - return days; -} - -function dayKey(at: Date): string { - return at.toISOString().slice(0, DAY_KEY_LENGTH); -} - -function daysBefore(day: string, count: number): string { - return dayKey(new Date(Date.parse(`${day}T00:00:00Z`) - count * MILLISECONDS_PER_DAY)); -} - -/** Never reads a clock: `today` is the caller's, so one request resolves the same way twice. - * The two days come back in order however they were given. */ -export function resolveReportPeriod( - request: ReportPeriodRequest, - today: Date -): ResolvedReportPeriod { - const span = request.days === undefined ? DEFAULT_REPORT_DAYS : parseSpan(request.days); - const toDay = request.to === undefined ? dayKey(today) : parseDay("--to", request.to); - const fromDay = - request.from === undefined ? daysBefore(toDay, span - 1) : parseDay("--from", request.from); - return fromDay <= toDay ? { fromDay, toDay } : { fromDay: toDay, toDay: fromDay }; -} diff --git a/cli/src/contexts/telemetry/domain/report/axes/day-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/day-rows.ts deleted file mode 100644 index f21c9cee7..000000000 --- a/cli/src/contexts/telemetry/domain/report/axes/day-rows.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** The day axis: every UTC day the period spans, whether or not a record landed on it, so - * a gap in the series is a printed zero rather than a missing row. */ - -import type { CostReportDayRow, TotalsAccumulator } from "../../cost-report.js"; -import { - type TelemetrySinkRecord, - telemetrySinkRecordDayKey, -} from "../../telemetry-sink-record.js"; - -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -/** Every UTC day from `fromDay` to `toDay`, inclusive. A day with nothing is still a row: a - * gap in a series reads as continuity, so the row has to exist to be a zero. */ -export function dayRange(fromDay: string, toDay: string): readonly string[] { - const days: string[] = []; - const end = Date.parse(`${toDay}T00:00:00Z`); - for (let at = Date.parse(`${fromDay}T00:00:00Z`); at <= end; at += MS_PER_DAY) { - days.push(new Date(at).toISOString().slice(0, 10)); - } - return days; -} - -/** Only a day the period itself spans, every one of them already seeded, so a record dated - * outside the period joins nothing rather than adding a day the report never covered. */ -export function addToDayGroup( - days: Map, - record: TelemetrySinkRecord -): void { - const day = telemetrySinkRecordDayKey(record); - if (day !== undefined && days.has(day)) days.get(day)?.add(record); -} - -/** Every day in the period, in order — never sorted by size, unlike every other breakdown - * here. A series read out of order is not a series. */ -export function dayRows(days: ReadonlyMap): readonly CostReportDayRow[] { - return [...days].map(([day, accumulator]) => ({ day, totals: accumulator.build() })); -} diff --git a/cli/src/contexts/telemetry/domain/report/axes/flow-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/flow-rows.ts deleted file mode 100644 index e5f2f44b6..000000000 --- a/cli/src/contexts/telemetry/domain/report/axes/flow-rows.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** The flow axis: keyed on the closed `FlowInterval` object a record's own moment falls - * inside, by reference, so two orchestrated runs of the same skill stay two rows. */ - -import type { - CostReportFlowRow, - CostReportSessionJournal, - TotalsAccumulator, -} from "../../cost-report.js"; -import { type FlowInterval, ORCHESTRATING_SKILLS } from "../../flow-attribution.js"; -import { momentFallsWithin } from "../../journal-intervals.js"; -import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; -import { bySize, isoSecondsFromMs } from "../row-ordering.js"; - -// A record falling in no flow interval at all is its own group, keyed on a symbol no real -// interval or skill name can ever equal. -const OUTSIDE_EVERY_FLOW = Symbol("record falls outside every flow interval"); - -// Keyed on the closed `FlowInterval` object itself, by reference, never on `skill` alone: two -// orchestrated runs of the same skill in one session are two distinct interval objects, and a -// `Map` keyed on object identity keeps them two rows without a synthesized composite key. -export type FlowRowKey = FlowInterval | string | typeof OUTSIDE_EVERY_FLOW; - -/** Every session's own closed flow intervals, keyed by vendor id - the same shape - * `allTaskIntervalsByVendorId` gives task intervals, one layer wider. */ -export function allFlowIntervalsByVendorId( - journals: readonly CostReportSessionJournal[] -): ReadonlyMap { - const byVendorId = new Map(); - for (const journal of journals) { - if (journal.flowIntervals.length > 0) byVendorId.set(journal.vendorId, journal.flowIntervals); - } - return byVendorId; -} - -/** Which flow interval a record's own moment falls inside, among all of its session's - * orchestrated runs - `OUTSIDE_EVERY_FLOW` for a record whose moment falls in none, with no - * taxonomy of why: a flow is read from the same sequence either way. Intervals within one - * session are closed and never overlap, so at most one ever matches. */ -export function flowKeyOf( - record: TelemetrySinkRecord, - intervalsByVendorId: ReadonlyMap -): FlowRowKey { - const intervals = intervalsByVendorId.get(record.vendor_id) ?? []; - const interval = intervals.find((candidate) => - momentFallsWithin([candidate], record.event_timestamp) - ); - return interval ?? flowTheToolNamed(record) ?? OUTSIDE_EVERY_FLOW; -} - -/** The orchestrating skill a record's own tool named, for a record no interval covers - the - * skill name itself as the key, which no `FlowInterval` object and no symbol can equal. - * - * Only `tool-stated`: a `journal-interval` step is inferred from the very intervals just - * checked, and a `prompt-matched` one names a step, not an orchestration. This capture exists - * because a session resumed after its context was compacted invokes nothing again, so no - * `step_start` fires and its journal opens no flow while the transcript goes on stating the - * step on every record it produces. */ -function flowTheToolNamed(record: TelemetrySinkRecord): string | undefined { - if (record.step_attribution !== "tool-stated") return undefined; - return record.step !== undefined && ORCHESTRATING_SKILLS.has(record.step) - ? record.step - : undefined; -} - -/** Every orchestrated run the period's journals name, largest first, then the one row for work - * that fell in no flow interval at all. No reason taxonomy, unlike `by_task`'s remainder: there - * is one fact to state about falling outside every flow, never several. That remainder is - * pinned last rather than sorted among the named rows, the same tail convention `taskRows` and - * `backlogRows` keep, since a breakdown ordering itself differently from its neighbours reads - * as a different kind of answer. */ -export function flowRows( - flows: ReadonlyMap -): readonly CostReportFlowRow[] { - const named: CostReportFlowRow[] = []; - let outsideEveryFlow: CostReportFlowRow | undefined; - for (const [key, accumulator] of flows) { - if (key === OUTSIDE_EVERY_FLOW) { - outsideEveryFlow = { attribution: "unattributed", totals: accumulator.build() }; - continue; - } - // A name is not a run: the tool-stated row is a bucket drawn from however many runs of - // that skill the tool named, so it carries no `startedAt`. - if (typeof key === "string") { - named.push({ flow: key, attribution: "tool-stated", totals: accumulator.build() }); - continue; - } - named.push({ - flow: key.skill, - attribution: "journal-interval", - startedAt: isoSecondsFromMs(key.startMs), - totals: accumulator.build(), - }); - } - const sorted = bySize( - named, - (row) => row.totals, - (row) => `${row.flow ?? ""}@${row.attribution}@${row.startedAt ?? ""}` - ); - return outsideEveryFlow === undefined ? sorted : [...sorted, outsideEveryFlow]; -} diff --git a/cli/src/contexts/telemetry/domain/report/axes/person-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/person-rows.ts deleted file mode 100644 index b28ad45ea..000000000 --- a/cli/src/contexts/telemetry/domain/report/axes/person-rows.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** The person axis: keyed on whichever field makes two records the same row - a mapped - * canonical id, an unresolved raw identifier, or the shared row for records naming none. */ - -import type { CostReportPersonRow, TotalsAccumulator } from "../../cost-report.js"; -import type { PersonResolution, ResolvedPerson } from "../../person-resolution.js"; -import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; -import { bySize } from "../row-ordering.js"; - -// A record with no identifier is its own row, keyed on a symbol - never folded into an -// unresolved row, which `PersonResolution`'s three-way shape requires stay distinct. -const NO_KNOWN_PERSON = Symbol("no known person"); -export type PersonRowKey = string | typeof NO_KNOWN_PERSON; - -// An empty string reads the same as absent: a tool writing `person_id: ""` has stated -// nothing, not named an identity nobody could ever claim. -export function personRawIdOf(record: TelemetrySinkRecord): string | undefined { - return typeof record.person_id === "string" && record.person_id !== "" - ? record.person_id - : undefined; -} - -/** One resolved person's group, keyed on whichever field makes two records the same row: a - * mapped record's canonical `personId`, so two raw identities one person declared merge; an - * unresolved record's own raw identifier, so two unplaced identities never merge into each - * other; or `NO_KNOWN_PERSON` for a record with none. */ -export interface PersonGroup { - readonly resolved: ResolvedPerson; - readonly totals: TotalsAccumulator; -} - -export function personGroupKey(resolved: ResolvedPerson): PersonRowKey { - if (resolved.resolution === "mapped" && resolved.personId !== undefined) { - return resolved.personId; - } - if (resolved.resolution === "unresolved") { - const [rawId] = resolved.identities; - if (rawId !== undefined) return rawId; - } - return NO_KNOWN_PERSON; -} - -function personRowOf(group: PersonGroup): CostReportPersonRow { - const { resolved } = group; - return { - resolution: resolved.resolution, - ...(resolved.personId === undefined ? {} : { person: resolved.personId }), - ...(resolved.displayName === undefined ? {} : { displayName: resolved.displayName }), - identities: resolved.identities, - totals: group.totals.build(), - }; -} - -/** The order every `by_person` breakdown is read in, strongest claim first: a person the - * record itself named, then the one this machine's identity claims, then every unplaced - * identity, then the one no-identifier row. A `Record` over the whole union rather than a - * filter per group: a filter silently drops whatever a future resolution does not name, and - * this shape makes that a compile error instead. */ -const PERSON_ROW_ORDER: Record = { - mapped: 0, - "this-machine": 1, - unresolved: 2, - none: 3, -}; - -/** Grouped in `PERSON_ROW_ORDER`, largest first within each group - `bySize` alone sorts - * purely on weight, so a large unresolved row would outrank a small mapped one. */ -export function personRows( - people: ReadonlyMap -): readonly CostReportPersonRow[] { - const rows = [...people.values()].map(personRowOf); - const keyOf = (row: CostReportPersonRow) => row.person ?? row.identities[0] ?? ""; - return Object.keys(PERSON_ROW_ORDER) - .sort( - (a, b) => PERSON_ROW_ORDER[a as PersonResolution] - PERSON_ROW_ORDER[b as PersonResolution] - ) - .flatMap((resolution) => - bySize( - rows.filter((row) => row.resolution === resolution), - (row) => row.totals, - keyOf - ) - ); -} diff --git a/cli/src/contexts/telemetry/domain/report/axes/record-stated-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/record-stated-rows.ts deleted file mode 100644 index 2c58f96e0..000000000 --- a/cli/src/contexts/telemetry/domain/report/axes/record-stated-rows.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** The four axes keyed on a value the record itself states directly - project, model, - * agent and prompt - each with its own sentinel for what named none. */ - -import type { AiToolId } from "../../../../../kernel/tool.js"; -import type { - CostReportAgentRow, - CostReportModelRow, - CostReportProjectRow, - CostReportPromptRow, - CostReportToolDeclaration, - TotalsAccumulator, -} from "../../cost-report.js"; -import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; -import { bySize, isoSecondsFromMs } from "../row-ordering.js"; - -// A record with no project is its own group, never folded into one that was placed. A symbol -// can never equal a real `project_id`, so it is a safe Map key for "unknown". -const NO_KNOWN_PROJECT = Symbol("no known project"); -export type ProjectKey = string | typeof NO_KNOWN_PROJECT; - -// An empty string is not a name - it is what a tool writes when it has none, and its own row -// would be one a person cannot act on. The `typeof` guard is separate: a record read off disk -// carries whatever its line held, not what this field's type declares. -export function projectKeyOf(record: TelemetrySinkRecord): ProjectKey { - return typeof record.project_id === "string" && record.project_id !== "" - ? record.project_id - : NO_KNOWN_PROJECT; -} - -// The same idea one dimension over: the Codex and OpenCode readers both permit a request -// record with no model, so without this row `byModels` would stop reconciling to its own -// total with nothing naming the gap. Narrower than `projectKeyOf` on purpose - nothing -// measured so far writes an empty-string `model`, so this stays an `undefined` check. -const NO_KNOWN_MODEL = Symbol("no known model"); -export type ModelKey = string | typeof NO_KNOWN_MODEL; - -// The main thread's own row, and the row for a tool that could never have named one. Symbols -// because an agent can be named anything, so no string is safe to reserve. -const MAIN_THREAD = Symbol("the main thread"); -const AGENT_NOT_STATED = Symbol("a tool whose route never names an agent"); -export type AgentKey = string | typeof MAIN_THREAD | typeof AGENT_NOT_STATED; - -/** Which of the three rows a record joins. `agent_name` present is the tool's own statement; - * absent means one of two different things, and only the tool's declaration tells them apart. - * The declaration is read rather than the record because the record cannot carry the absence: - * a tool that never names an agent writes exactly what a main-thread line writes. */ -export function agentKeyOf( - record: TelemetrySinkRecord, - namesAgents: (tool: AiToolId) => boolean -): AgentKey { - if (record.agent_name !== undefined) return record.agent_name; - return namesAgents(record.tool) ? MAIN_THREAD : AGENT_NOT_STATED; -} - -/** Whether a tool's own declared route names agents, answered from `declaredTools` alone. A - * tool with no declared local read supplies nothing, so it names no agent either. */ -export function agentNamingTools( - declaredTools: readonly CostReportToolDeclaration[] -): (tool: AiToolId) => boolean { - const naming = new Set( - declaredTools - .filter((declaration) => declaration.capability.localRead?.agentName === true) - .map((declaration) => declaration.tool) - ); - return (tool) => naming.has(tool); -} - -// The row for what named no prompt. A symbol because a prompt id is opaque and host-assigned, -// so no string is safe to reserve against it. -const NO_PROMPT = Symbol("no prompt named"); -export type PromptKey = string | typeof NO_PROMPT; - -export function promptKeyOf(record: TelemetrySinkRecord): PromptKey { - return record.prompt_id === undefined ? NO_PROMPT : record.prompt_id; -} - -export function modelKeyOf(record: TelemetrySinkRecord): ModelKey { - return record.model === undefined ? NO_KNOWN_MODEL : record.model; -} - -/** A prompt's running totals plus the earliest moment seen in it, tracked here rather than - * read back off the records: the pass over them happens once, and a sink is append-ordered by - * when a record was read, never by when a turn began. */ -export interface PromptGroup { - readonly totals: TotalsAccumulator; - earliestMs?: number; -} - -/** Every project a record named, largest first, plus one row for what named none. */ -export function projectRows( - projects: ReadonlyMap -): readonly CostReportProjectRow[] { - const rows: CostReportProjectRow[] = [...projects].map(([key, accumulator]) => ({ - ...(key === NO_KNOWN_PROJECT ? {} : { project: key }), - totals: accumulator.build(), - })); - return bySize( - rows, - (row) => row.totals, - (row) => row.project ?? "" - ); -} - -/** Every agent that ran, largest first, plus one row for the main thread. */ -export function agentRows( - agents: ReadonlyMap -): readonly CostReportAgentRow[] { - const rows: CostReportAgentRow[] = [...agents].map(([key, accumulator]) => { - if (key === MAIN_THREAD) return { attribution: "main-thread", totals: accumulator.build() }; - if (key === AGENT_NOT_STATED) return { attribution: "not-stated", totals: accumulator.build() }; - return { agent: key, attribution: "tool-stated", totals: accumulator.build() }; - }); - return bySize( - rows, - (row) => row.totals, - (row) => `${row.agent ?? ""}@${row.attribution}` - ); -} - -/** Every prompt that caused work, largest first, plus one row for what named none. Not - * chronological: unlike `by_day` this is a ranking, with no continuity to break. The row for - * what named none is pinned last rather than ranked - a remainder drawn from many turns is - * not a turn, so its size is not comparable to theirs. */ -export function promptRows( - prompts: ReadonlyMap -): readonly CostReportPromptRow[] { - const named: CostReportPromptRow[] = []; - let namedNone: CostReportPromptRow | undefined; - for (const [key, group] of prompts) { - const totals = group.totals.build(); - if (key === NO_PROMPT) { - namedNone = { totals }; - continue; - } - named.push({ - prompt: key, - ...(group.earliestMs === undefined ? {} : { startedAt: isoSecondsFromMs(group.earliestMs) }), - totals, - }); - } - const sorted = bySize( - named, - (row) => row.totals, - (row) => row.prompt ?? "" - ); - return namedNone === undefined ? sorted : [...sorted, namedNone]; -} - -/** Every model a record named, largest first, plus one row for what named none. */ -export function modelRows( - models: ReadonlyMap -): readonly CostReportModelRow[] { - const rows: CostReportModelRow[] = [...models].map(([key, accumulator]) => ({ - ...(key === NO_KNOWN_MODEL ? {} : { model: key }), - totals: accumulator.build(), - })); - return bySize( - rows, - (row) => row.totals, - (row) => row.model ?? "" - ); -} diff --git a/cli/src/contexts/telemetry/domain/report/axes/step-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/step-rows.ts deleted file mode 100644 index 6c7f06517..000000000 --- a/cli/src/contexts/telemetry/domain/report/axes/step-rows.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** The step axis: keyed by the step and the strength of its own attribution, never by the - * step alone, so a tool-stated reach and a journal-inferred one stay two rows. */ - -import type { - CostReportAttributionRow, - CostReportStepRow, - TotalsAccumulator, -} from "../../cost-report.js"; -import { STEP_ATTRIBUTION_SOURCES, type StepAttributionSource } from "../../step-attribution.js"; -import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; -import { bySize } from "../row-ordering.js"; - -// A single space cannot occur in a `step_attribution` value, so it separates the two parts of -// the key unambiguously even though a skill name could contain almost anything. The group -// keeps both parts beside its counters rather than parsing them back out of the key, which -// would mean asserting a type back out of a string. -const STEP_ROW_SEPARATOR = " "; - -export interface StepGroup { - readonly attribution: StepAttributionSource; - readonly step?: string; - readonly totals: TotalsAccumulator; -} - -export function stepRowKey(record: TelemetrySinkRecord): string { - return `${record.step_attribution}${STEP_ROW_SEPARATOR}${record.step ?? ""}`; -} - -/** All four, always, in the declared order. A strength that accounted for nothing is the one - * place in this report where a zero is the measurement rather than an absence, and dropping - * the row would leave a consumer unable to tell "no records were attributed this way" from - * "this report does not carry that field". */ -export function attributionRows( - attributions: ReadonlyMap -): readonly CostReportAttributionRow[] { - return STEP_ATTRIBUTION_SOURCES.map((attribution) => ({ - attribution, - totals: attributions.get(attribution)?.build() ?? { requests: 0 }, - })); -} - -export function stepRows(steps: ReadonlyMap): readonly CostReportStepRow[] { - const rows: CostReportStepRow[] = [...steps.values()].map((group) => ({ - attribution: group.attribution, - ...(group.step === undefined ? {} : { step: group.step }), - totals: group.totals.build(), - })); - return bySize( - rows, - (row) => row.totals, - (row) => `${row.step ?? ""}/${row.attribution}` - ); -} diff --git a/cli/src/contexts/telemetry/domain/report/axes/task-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/task-rows.ts deleted file mode 100644 index 461da284b..000000000 --- a/cli/src/contexts/telemetry/domain/report/axes/task-rows.ts +++ /dev/null @@ -1,255 +0,0 @@ -/** The task and backlog axes: keyed on the declared interval a record's own moment falls - * in, falling back to a session's written files, never merging the two attribution routes. */ - -import type { - CostReportBacklogRow, - CostReportSessionJournal, - CostReportTaskAttributionRow, - CostReportTaskRow, - TotalsAccumulator, -} from "../../cost-report.js"; -import { momentFallsWithin } from "../../journal-intervals.js"; -import { - TASK_ATTRIBUTION_SOURCES, - TASK_UNATTRIBUTED_REASONS, - type TaskAttributionSource, - type TaskInterval, - type TaskUnattributedReason, - taskUnattributedReason, -} from "../../task-attribution.js"; -import type { TaskBacklogDeclaration } from "../../task-backlog-link.js"; -import { - type TaskIdentity, - taskIdentitiesFromWrittenPaths, - taskIdentityFromWrittenPath, -} from "../../task-identity.js"; -import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; -import { bySize } from "../row-ordering.js"; - -/** How a record came to belong to a task, or why it belongs to none - the value every task - * axis keys on, computed once per record. A named membership carries `attribution` beside the - * identity because one task holds records from both routes, and a single row carrying the - * weaker attribution would state something false about the records that were declared. */ -export interface TaskGroup { - readonly task?: TaskIdentity; - readonly attribution?: TaskAttributionSource; - readonly reason?: TaskUnattributedReason; - readonly totals: TotalsAccumulator; -} - -interface TaskMembershipRow { - readonly task: TaskIdentity; - readonly attribution: TaskAttributionSource; -} - -export type TaskRow = TaskMembershipRow | TaskUnattributedReason; - -const TASK_ROW_SEPARATOR = " "; - -/** Mirrors `stepRowKey`, which keys its own `(name x attribution)` pairs the same way. A - * `TaskIdentity` is always `${month}/${name}` and an attribution is never one, so a named key - * can never collide with a reason key. */ -export function taskRowKeyOf(row: TaskRow): string { - return typeof row === "string" ? row : `${row.attribution}${TASK_ROW_SEPARATOR}${row.task}`; -} - -/** The one task a session's written files name, when they name exactly one. Two written - * folders infer nothing: two candidates and no reason to choose between them. That refusal is - * the bound that makes this route sound, since attributing a whole session otherwise places - * one session under two task rows at once. */ -function soleWrittenTaskOf(journal: CostReportSessionJournal | undefined): TaskIdentity | null { - if (journal === undefined) return null; - const identities = new Set(taskIdentitiesFromWrittenPaths(journal.writtenPaths)); - if (identities.size !== 1) return null; - const [only] = identities; - return only ?? null; -} - -/** Whether this journal witnessed `momentIso` at all - never an unbounded yes for a journal - * that carries no readable moment. */ -function witnessed( - journal: CostReportSessionJournal | undefined, - momentIso: string | undefined -): boolean { - const span = journal?.witnessed; - if (span === undefined || momentIso === undefined) return false; - const momentMs = Date.parse(momentIso); - if (Number.isNaN(momentMs)) return false; - return momentMs >= span.fromMs && momentMs <= span.toMs; -} - -// One level above a task: a task whose folder declares no backlog item, and one whose -// declaration could not be read, are two groups, never folded into each other or into a named -// item. Symbols because a backlog item is a free-form string on either support, so no string -// sentinel could be ruled out colliding with a real one. -const NO_BACKLOG_DECLARED = Symbol("task declares no backlog item"); -const UNREADABLE_BACKLOG_DECLARATION = Symbol("task's backlog declaration could not be read"); -export type BacklogRowKey = - | string - | typeof NO_BACKLOG_DECLARED - | typeof UNREADABLE_BACKLOG_DECLARATION - | TaskUnattributedReason; - -/** Every session's own closed intervals, keyed by vendor id - never a second notion of when a - * task was running. Unlike `declaredIntervalsForTask` this keeps every task a session declared, - * since `byTasks` groups by whichever task a record's moment falls in. Every journal gets an - * entry, including one that declared nothing: the empty list and the absent key are two - * different facts, and `taskRowOf` reads them as two. */ -export function allTaskIntervalsByVendorId( - journals: readonly CostReportSessionJournal[] -): ReadonlyMap { - const byVendorId = new Map(); - for (const journal of journals) byVendorId.set(journal.vendorId, journal.taskIntervals); - return byVendorId; -} - -/** Which task a record's own moment falls inside, among *all* of its session's declared - * intervals - `taskUnattributedReason` for a record whose moment falls in none. Intervals - * within one session are closed and never overlap, so at most one ever matches. - * - * `interval.path` failing to resolve is unreachable through this codebase's own wiring but not - * through the type: `taskIntervals` is a plain input field, so a caller can still hand this an - * interval whose path resolves to nothing, which is why the fallback stays. Reading such a - * moment as no interval covering it is deliberate - a path this layer cannot turn into an - * identity names no task a person could act on either. */ -export function taskRowOf( - record: TelemetrySinkRecord, - intervalsByVendorId: ReadonlyMap, - journalsByVendorId: ReadonlyMap -): TaskRow { - const intervals = intervalsByVendorId.get(record.vendor_id); - // No entry at all means no journal was read for this session - never that it declared - // nothing. `allTaskIntervalsByVendorId` gives every journal it read an entry, so the two - // cases are distinguishable here and nowhere else. - if (intervals === undefined) return "no-journal"; - const interval = intervals.find((candidate) => - momentFallsWithin([candidate], record.event_timestamp) - ); - const declared = interval && taskIdentityFromWrittenPath(interval.path); - if (declared) return { task: declared, attribution: "declared" }; - // Only now the weaker route, and only inside what this journal witnessed: a declaration - // that covers the record always wins, so this never overrides a stated fact with an - // inferred one. - const journal = journalsByVendorId.get(record.vendor_id); - const inferred = soleWrittenTaskOf(journal); - if (inferred !== null && witnessed(journal, record.event_timestamp)) { - return { task: inferred, attribution: "inferred" }; - } - // The journal's own earliest witnessed moment, so a record older than everything this - // session saw is named for that rather than for declaring late. Absent for a journal with no - // readable moment, which then makes no coverage claim at all. - return taskUnattributedReason(intervals, record.event_timestamp, journal?.witnessed?.fromMs); -} - -/** Which `byBacklog` row a record's own task-row key belongs in - built from `taskRowOf`'s - * output, never a second notion of which task a record fell inside. A reason passes straight - * through unchanged; a named task looks up its folder's declaration once, in the map the - * caller already resolved. A named task missing from `declarations` is unreachable through the - * one production caller but reads as `{ kind: "none" }` rather than throwing or dropping the - * record, so no gap in wiring this module cannot see can lose a record's figures. */ -export function backlogKeyOf( - taskRow: TaskRow, - declarations: ReadonlyMap | undefined -): BacklogRowKey { - if (typeof taskRow === "string") return taskRow; - const declaration = declarations?.get(taskRow.task) ?? { kind: "none" as const }; - if (declaration.kind === "none") return NO_BACKLOG_DECLARED; - if (declaration.kind === "unreadable") return UNREADABLE_BACKLOG_DECLARATION; - return declaration.link.backlog; -} - -/** Both sources, always, the same as `attributionRows`: a source that accounted for nothing is - * still a fact about this task, not an absent field. */ -export function taskAttributionRows( - taskAttributions: ReadonlyMap -): readonly CostReportTaskAttributionRow[] { - return TASK_ATTRIBUTION_SOURCES.map((attribution) => ({ - attribution, - totals: taskAttributions.get(attribution)?.build() ?? { requests: 0 }, - })); -} - -// Typed over `string | symbol`, wider than `BacklogRowKey`, since a `backlog` map's key can -// also be a symbol - safe because every reason is a plain string, which no symbol equals. -function isTaskUnattributedReason(key: string | symbol): key is TaskUnattributedReason { - return typeof key === "string" && (TASK_UNATTRIBUTED_REASONS as readonly string[]).includes(key); -} - -/** Every task a record's own moment fell inside, largest first, then one row per reason - * actually present for what fell in none - `TASK_UNATTRIBUTED_REASONS`' fixed order, always - * after every named task regardless of size. Never fewer rows than the reasons present: two - * different gaps collapsed into one row is the fault this breakdown exists to avoid. */ -export function taskRows(tasks: ReadonlyMap): readonly CostReportTaskRow[] { - const named: CostReportTaskRow[] = []; - const byReason = new Map(); - for (const group of tasks.values()) { - const totals = group.totals.build(); - if (group.reason !== undefined) { - byReason.set(group.reason, { reason: group.reason, totals }); - continue; - } - if (group.task === undefined || group.attribution === undefined) continue; - named.push({ task: group.task, attribution: group.attribution, totals }); - } - // Tie-broken on the pair, not on the task alone: one task can hold both a declared row and - // an inferred one, and a tie-break blind to the attribution would order them arbitrarily. - const sorted = bySize( - named, - (row) => row.totals, - (row) => `${row.task ?? ""}/${row.attribution ?? ""}` - ); - const reasonRows = TASK_UNATTRIBUTED_REASONS.map((reason) => byReason.get(reason)).filter( - (row): row is CostReportTaskRow => row !== undefined - ); - return [...sorted, ...reasonRows]; -} - -interface BacklogGroups { - readonly named: readonly CostReportBacklogRow[]; - readonly byReason: ReadonlyMap; - readonly none: CostReportBacklogRow | undefined; - readonly unreadable: CostReportBacklogRow | undefined; -} - -// One pass classifying every backlog key into the four shapes a row can be - named, -// unattributed-by-reason, declared none, or unreadable - nothing sorted yet. -function classifyBacklogGroups( - backlog: ReadonlyMap -): BacklogGroups { - const named: CostReportBacklogRow[] = []; - const byReason = new Map(); - let none: CostReportBacklogRow | undefined; - let unreadable: CostReportBacklogRow | undefined; - for (const [key, accumulator] of backlog) { - if (isTaskUnattributedReason(key)) { - byReason.set(key, { reason: key, totals: accumulator.build() }); - } else if (key === NO_BACKLOG_DECLARED) { - none = { declaration: "none", totals: accumulator.build() }; - } else if (key === UNREADABLE_BACKLOG_DECLARATION) { - unreadable = { declaration: "unreadable", totals: accumulator.build() }; - } else { - named.push({ backlog: key, totals: accumulator.build() }); - } - } - return { named, byReason, none, unreadable }; -} - -/** Every backlog item a task declared, largest first, then the two rows for a known task that - * named none or could not be read, then one row per reason a record fell in no task at all - - * the same tail convention `taskRows` uses. Two tasks declaring the same item merge by - * construction, on the identical `backlog` key, never in a second merge step that could - * disagree with how every other axis reconciles. */ -export function backlogRows( - backlog: ReadonlyMap -): readonly CostReportBacklogRow[] { - const { named, byReason, none, unreadable } = classifyBacklogGroups(backlog); - const sorted = bySize( - named, - (row) => row.totals, - (row) => row.backlog ?? "" - ); - const reasonRows = TASK_UNATTRIBUTED_REASONS.map((reason) => byReason.get(reason)).filter( - (row): row is CostReportBacklogRow => row !== undefined - ); - return [...sorted, ...(none ? [none] : []), ...(unreadable ? [unreadable] : []), ...reasonRows]; -} diff --git a/cli/src/contexts/telemetry/domain/report/axes/tool-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/tool-rows.ts deleted file mode 100644 index e16181741..000000000 --- a/cli/src/contexts/telemetry/domain/report/axes/tool-rows.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** The tool axis: one row per declared tool, whether or not it contributed, so an - * unreadable one shows its own reason instead of a false zero. */ - -import type { AiToolId } from "../../../../../kernel/tool.js"; -import type { - CostReportFilters, - CostReportToolDeclaration, - CostReportToolRow, - TotalsAccumulator, -} from "../../cost-report.js"; - -/** `by_tool` is a breakdown of every *declared* tool, not only the ones a record touched, so - * an unreadable one shows its reason instead of a false zero. A `--tool` filter narrows that - * same list, or every tool it excluded would still print "nothing in this period" - - * indistinguishable from one genuinely measured idle. */ -export function declaredToolsInScope( - declaredTools: readonly CostReportToolDeclaration[], - filters: CostReportFilters | undefined -): readonly CostReportToolDeclaration[] { - const wanted = filters?.tool; - return wanted === undefined - ? declaredTools - : declaredTools.filter((tool) => tool.tool === wanted); -} - -/** Every declared tool gets a row, in the declared order, whether or not it contributed: a - * tool absent from the output is one a reader assumes did nothing, which for an unreadable - * tool is the false zero this layer exists to prevent. */ -export function buildToolRows( - declaredTools: readonly CostReportToolDeclaration[], - measured: ReadonlyMap, - sessionTotals: ReadonlyMap -): readonly CostReportToolRow[] { - return declaredTools.map((declaration) => { - const session = sessionTotals.get(declaration.tool); - return { - tool: declaration.tool, - coverage: declaration.coverage, - ...(declaration.reason === undefined ? {} : { reason: declaration.reason }), - capability: declaration.capability, - totals: measured.get(declaration.tool)?.build() ?? { requests: 0 }, - ...(session === undefined ? {} : { sessionTotals: session.build() }), - }; - }); -} diff --git a/cli/src/contexts/telemetry/domain/report/record-counters.ts b/cli/src/contexts/telemetry/domain/report/record-counters.ts deleted file mode 100644 index 882e1060c..000000000 --- a/cli/src/contexts/telemetry/domain/report/record-counters.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** The four token counters a record can carry, and the record field each one is read from. */ - -import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; - -// The list first, the type derived from it: reading the keys back off the table would have to -// assert their type, and an assertion stops holding the day the two disagree. -export const COUNTER_FIELDS = [ - "inputTokens", - "outputTokens", - "cacheReadTokens", - "cacheCreationTokens", -] as const; - -export type CounterField = (typeof COUNTER_FIELDS)[number]; - -export const COUNTER_SOURCE: Readonly> = { - inputTokens: "input_tokens", - outputTokens: "output_tokens", - cacheReadTokens: "cache_read_tokens", - cacheCreationTokens: "cache_creation_tokens", -}; diff --git a/cli/src/contexts/telemetry/domain/report/record-reconciliation.ts b/cli/src/contexts/telemetry/domain/report/record-reconciliation.ts deleted file mode 100644 index cba689d79..000000000 --- a/cli/src/contexts/telemetry/domain/report/record-reconciliation.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** Reconciles records read more than once - a still-open turn re-read locally, and one - * billed call seen by two live routes - into the one record a report may safely sum. */ - -import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; -import { COUNTER_FIELDS, COUNTER_SOURCE } from "./record-counters.js"; - -/** A group key only for a `kind: "request"`, `provenance: "local-read"` record carrying a - * `turn_id` — the shape a local re-read of a still-running turn produces more than one of. A - * `kind: "session"` record can carry a `turn_id` too, but it is a one-shot cumulative figure - * with no provisional reading to collapse; on the export route the same field is a prompt id - * several billed calls share, so the identical key there would merge distinct calls. */ -function localReadTurnKey(record: TelemetrySinkRecord): string | null { - if (record.kind !== "request" || record.provenance !== "local-read") return null; - return record.turn_id === undefined - ? null - : `${record.tool} ${record.vendor_id} ${record.turn_id}`; -} - -/** How much of a group a record accounts for, used only to pick the largest of several - * readings of one still-growing turn — never stored, never itself summed into a total. */ -function counterWeight(record: TelemetrySinkRecord): number { - return COUNTER_FIELDS.reduce((sum, field) => { - const value = record[COUNTER_SOURCE[field]]; - return sum + (typeof value === "number" ? value : 0); - }, 0); -} - -/** How many of the four counters a record states at all, whether zero or not — the tie-break - * beyond `counterWeight` alone, since an observed zero and a counter never mentioned both add - * zero to the weight. Preferring the record that states more never risks preferring a shrink: - * the write-time guard already refused a candidate dropping a counter the stored one had. */ -function definedCounterCount(record: TelemetrySinkRecord): number { - return COUNTER_FIELDS.reduce( - (count, field) => count + (typeof record[COUNTER_SOURCE[field]] === "number" ? 1 : 0), - 0 - ); -} - -/** - * One turn read more than once while it was still open, collapsed to the record carrying the - * most complete counters. Never done at write time: the sink is append-only, so a partial - * earlier reading is reconciled by whatever reads it back. Every record here came from the - * same route reading the same file at different moments, so the survivor is whichever carries - * the largest counters — never a blend of two, which would state a combination the tool's own - * file never reported together, and never a shrink over the larger reading. */ -function mergeSupersededTurnGroup(group: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { - if (group.length === 1) return group[0]; - const heaviest = Math.max(...group.map(counterWeight)); - const largest = group.filter((record) => counterWeight(record) === heaviest); - const mostDefined = Math.max(...largest.map(definedCounterCount)); - return pickDeterministically( - largest.filter((record) => definedCounterCount(record) === mostDefined) - ); -} - -/** Every other kind and route passes through untouched — see `localReadTurnKey`. */ -export function collapseSupersededTurns( - records: readonly TelemetrySinkRecord[] -): readonly TelemetrySinkRecord[] { - const groups = new Map(); - const rest: TelemetrySinkRecord[] = []; - for (const record of records) { - const key = localReadTurnKey(record); - if (key === null) { - rest.push(record); - continue; - } - const bucket = groups.get(key); - if (bucket) bucket.push(record); - else groups.set(key, [record]); - } - return [...rest, ...[...groups.values()].map(mergeSupersededTurnGroup)]; -} - -/** A group key only where `billed_request_id` is present — the one stable, cross-route - * identifier for a single billed call, unlike `turn_id`, which a main-agent request and its - * subagent share. A record with none joins nothing and is left exactly as it arrived. */ -function billedRequestKey(record: TelemetrySinkRecord): string | null { - return record.billed_request_id === undefined - ? null - : `${record.tool}\0${record.vendor_id}\0${record.billed_request_id}`; -} - -/** The same group, from any starting order, always answers the same record. A group's own - * order is never guaranteed — redelivery can duplicate an export record, and a re-read joins - * a session's stored records in whatever order the day files listed them — so picking - * `group[0]` would make the survivor depend on that accident; sorting on each candidate's own - * serialized content does not. */ -function pickDeterministically(candidates: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { - return [...candidates].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))[0]; -} - -/** Borrows `step_attribution`/`step`/`step_plugin` from a sibling that resolved one, when - * `base`'s own is `"unattributed"` — the export route never states a step at all, so - * leaving it as the survivor by default would throw away the one thing the local-read - * route in the same group did know, preferring a tool-stated step over a journal-interval - * one where both exist. */ -function withStepBackfill( - base: TelemetrySinkRecord, - group: readonly TelemetrySinkRecord[] -): TelemetrySinkRecord { - if (base.step_attribution !== "unattributed") return base; - const stepDonors = group.filter( - (record) => record !== base && record.step_attribution !== "unattributed" - ); - if (stepDonors.length === 0) return base; - const toolStated = stepDonors.filter((record) => record.step_attribution === "tool-stated"); - const donor = pickDeterministically(toolStated.length > 0 ? toolStated : stepDonors); - return { - ...base, - step_attribution: donor.step_attribution, - step: donor.step, - step_plugin: donor.step_plugin, - }; -} - -/** `person_id` and `person_display_name`, backfilled onto `base` as a pair, never one field - * from each: only the local-read side of a billed call carries a person, so keeping the export - * sibling instead would silently report a mapped person's work as `"none"`. Independent of - * `withStepBackfill`, never chained after it, which returns early once a step is resolved - * while person still has to be checked. */ -function withPersonBackfill( - base: TelemetrySinkRecord, - group: readonly TelemetrySinkRecord[] -): TelemetrySinkRecord { - if (base.person_id !== undefined) return base; - const donors = group.filter((record) => record.person_id !== undefined); - if (donors.length === 0) return base; - const donor = pickDeterministically(donors); - return { - ...base, - person_id: donor.person_id, - ...(donor.person_display_name === undefined - ? {} - : { person_display_name: donor.person_display_name }), - }; -} - -/** One billed call, seen once by each of two live routes, collapsed to the one record a report - * may safely sum. Never done at write time: the sink is append-only, so a stored record is - * only ever reconciled by whatever reads it back. The survivor keeps whichever record carries - * `cost_usd`, which on every tool measured so far is also the one whose token counters are - * complete, so the group's money is never summed from more than one record. */ -function mergeBilledRequestGroup(group: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { - if (group.length === 1) return group[0]; - const costBearing = group.filter((record) => record.cost_usd !== undefined); - const base = pickDeterministically(costBearing.length > 0 ? costBearing : group); - return withPersonBackfill(withStepBackfill(base, group), group); -} - -/** `kind: "session"` records are never part of a billed-call group — no metric datapoint - * measured so far carries `billed_request_id` — so only `kind: "request"` records join one. */ -export function collapseBilledRequests( - records: readonly TelemetrySinkRecord[] -): readonly TelemetrySinkRecord[] { - const groups = new Map(); - const rest: TelemetrySinkRecord[] = []; - for (const record of records) { - const key = record.kind === "request" ? billedRequestKey(record) : null; - if (key === null) { - rest.push(record); - continue; - } - const bucket = groups.get(key); - if (bucket) bucket.push(record); - else groups.set(key, [record]); - } - return [...rest, ...[...groups.values()].map(mergeBilledRequestGroup)]; -} diff --git a/cli/src/contexts/telemetry/domain/report/report-selection.ts b/cli/src/contexts/telemetry/domain/report/report-selection.ts deleted file mode 100644 index 99b88383d..000000000 --- a/cli/src/contexts/telemetry/domain/report/report-selection.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** Narrows a period's records to what a `--task` and a set of generic filters both admit, - * one composing stage at a time, and says which filter emptied the selection when one did. */ - -import type { - CostReportEmptySelection, - CostReportFilterName, - CostReportFilters, - CostReportInput, - CostReportSessionJournal, -} from "../cost-report.js"; -import { momentFallsWithin } from "../journal-intervals.js"; -import type { TaskAttributionSource, TaskInterval } from "../task-attribution.js"; -import { - type TaskIdentity, - taskIdentitiesFromWrittenPaths, - taskIdentityFromWrittenPath, -} from "../task-identity.js"; -import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; - -/** The vendor ids whose sessions wrote into `task` at some point - deliberately - * whole-session, unlike the per-record precision a declared interval gives. */ -function inferredVendorIdsForTask( - journals: readonly CostReportSessionJournal[], - task: TaskIdentity -): ReadonlySet { - const vendorIds = new Set(); - for (const journal of journals) { - if (taskIdentitiesFromWrittenPaths(journal.writtenPaths).includes(task)) { - vendorIds.add(journal.vendorId); - } - } - return vendorIds; -} - -/** Every session's own declared intervals that name `task`, keyed by vendor id so a record's - * session is a lookup rather than a walk of every journal again. A session that never declared - * this task carries no entry, so it reads as belonging to none, never to the last one seen. */ -function declaredIntervalsForTask( - journals: readonly CostReportSessionJournal[], - task: TaskIdentity -): ReadonlyMap { - const byVendorId = new Map(); - for (const journal of journals) { - const intervals = journal.taskIntervals.filter( - (interval) => taskIdentityFromWrittenPath(interval.path) === task - ); - if (intervals.length > 0) byVendorId.set(journal.vendorId, intervals); - } - return byVendorId; -} - -/** Both routes to `task`, kept apart rather than merged into one vendor-id set: a declared - * interval decides per record, a written file for a session's records as a whole. Merging them - * would let a zero-width or long-closed declaration - real, but covering no record - drag in - * records a written file never touched either. */ -export interface TaskMembership { - readonly declaredIntervalsByVendorId: ReadonlyMap; - readonly inferredVendorIds: ReadonlySet; -} - -export function taskMembership( - journals: readonly CostReportSessionJournal[], - task: TaskIdentity -): TaskMembership { - return { - declaredIntervalsByVendorId: declaredIntervalsForTask(journals, task), - inferredVendorIds: inferredVendorIdsForTask(journals, task), - }; -} - -/** How, if at all, one record belongs to the task `membership` was built for - `undefined` for - * neither route, which excludes it from a `--task` report entirely. Only a record a - * declaration does not cover falls back to whether its whole session wrote into the folder. */ -export function taskAttributionOf( - record: TelemetrySinkRecord, - membership: TaskMembership -): TaskAttributionSource | undefined { - const intervals = membership.declaredIntervalsByVendorId.get(record.vendor_id); - if (intervals && momentFallsWithin(intervals, record.event_timestamp)) return "declared"; - return membership.inferredVendorIds.has(record.vendor_id) ? "inferred" : undefined; -} - -const GENERIC_FILTER_FIELDS: Readonly> = - { - project: "project_id", - step: "step", - model: "model", - tool: "tool", - }; -const GENERIC_FILTER_ORDER: readonly (keyof CostReportFilters)[] = [ - "project", - "step", - "model", - "tool", -]; - -interface SelectionStage { - readonly name: CostReportFilterName | undefined; - readonly value: string | undefined; - readonly records: readonly TelemetrySinkRecord[]; -} - -/** One stage per active filter, each narrowing what the stage before it kept. Filters - * compose by `and` and nothing else: every stage only ever removes records the one before - * it was already going to keep, never adds one back. */ -export function selectionStages( - records: readonly TelemetrySinkRecord[], - input: CostReportInput, - membership: TaskMembership | null -): readonly SelectionStage[] { - const stages: SelectionStage[] = [{ name: undefined, value: undefined, records }]; - if (membership !== null) { - const kept = records.filter((r) => taskAttributionOf(r, membership) !== undefined); - stages.push({ name: "task", value: input.task, records: kept }); - } - for (const name of GENERIC_FILTER_ORDER) { - const value = input.filters?.[name]; - if (value === undefined) continue; - const field = GENERIC_FILTER_FIELDS[name]; - const previous = stages[stages.length - 1]?.records ?? []; - stages.push({ name, value, records: previous.filter((r) => r[field] === value) }); - } - return stages; -} - -/** Whether a filter's own value is known at all - anywhere this call can see, not only in this - * selection. `tool` reads the declared list, a closed set; the rest read `knownValues`, - * gathered across every day file the caller looked at, not only the period's own records. */ -function isKnownFilterValue( - name: CostReportFilterName, - value: string, - input: CostReportInput, - membership: TaskMembership | null -): boolean { - if (name === "task") { - return ( - (membership?.declaredIntervalsByVendorId.size ?? 0) > 0 || - (membership?.inferredVendorIds.size ?? 0) > 0 - ); - } - if (name === "tool") return input.declaredTools.some((tool) => tool.tool === value); - const known = input.knownValues ?? { projects: new Set(), steps: new Set(), models: new Set() }; - const set = { project: known.projects, step: known.steps, model: known.models }[name]; - return set?.has(value) ?? false; -} - -/** True when the culprit filter's own value matched something before any generic filter ran, - * so the emptiness comes from an intersection rather than from this value alone. `task` has no - * "alone" reading - it is the only route to a task, not one composed equality check. */ -function isCombinationCulprit( - stages: readonly SelectionStage[], - membership: TaskMembership | null, - culprit: SelectionStage -): boolean { - if (culprit.name === undefined || culprit.name === "task") return false; - const field = GENERIC_FILTER_FIELDS[culprit.name]; - const baseline = stages[membership === null ? 0 : 1]?.records ?? []; - return baseline.some((r) => r[field] === culprit.value); -} - -/** The first filter that narrowed a non-empty selection down to nothing - never the - * period itself, which is an honest zero rather than a filter's doing. Stages only ever - * shrink, so the first empty one is the whole answer to "which filter emptied it". */ -export function emptySelectionOf( - stages: readonly SelectionStage[], - input: CostReportInput, - membership: TaskMembership | null -): CostReportEmptySelection | undefined { - if ((stages[0]?.records.length ?? 0) === 0) return undefined; - const culprit = stages.find((stage) => stage.records.length === 0); - if (!culprit || culprit.name === undefined || culprit.value === undefined) return undefined; - const known = isKnownFilterValue(culprit.name, culprit.value, input, membership); - const combination = isCombinationCulprit(stages, membership, culprit); - return { - filter: culprit.name, - value: culprit.value, - known, - ...(combination ? { combination: true } : {}), - }; -} - -/** Which of the four generic filters were actually given, in the same fixed order - never - * `task`, which keeps its own top-level field unchanged. `undefined` when none were, so - * an unfiltered period carries no empty object. */ -export function activeFilters( - filters: CostReportFilters | undefined -): CostReportFilters | undefined { - if (!filters) return undefined; - const given = GENERIC_FILTER_ORDER.filter((name) => filters[name] !== undefined); - if (given.length === 0) return undefined; - return Object.fromEntries(given.map((name) => [name, filters[name]])); -} diff --git a/cli/src/contexts/telemetry/domain/report/row-ordering.ts b/cli/src/contexts/telemetry/domain/report/row-ordering.ts deleted file mode 100644 index 9dbc0bcc2..000000000 --- a/cli/src/contexts/telemetry/domain/report/row-ordering.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** How a breakdown orders its rows: largest amount first, tokens as the fallback weight - * when a row is costless, and a moment rendered to second precision. */ - -import type { CostTotals } from "../cost-report.js"; - -/** Every token a row counted, across all four disjoint counters - the weight `bySize` falls - * back to for a costless row. Never `inputTokens + outputTokens` alone: tools run mostly on - * cache, so a weight blind to the cache counters would invert the order, and this is also the - * same sum the report prints beside a costless row. */ -function tokensOf(totals: CostTotals): number { - return ( - (totals.inputTokens ?? 0) + - (totals.outputTokens ?? 0) + - (totals.cacheReadTokens ?? 0) + - (totals.cacheCreationTokens ?? 0) - ); -} - -/** Largest first, so the biggest thing is the first thing read. Weighted by amount where - * one exists and by tokens where none does, since a tool with no amount would otherwise - * sort as if it had cost nothing. Ties fall back to the row's own key, so the same records - * always produce the same report. */ -export function bySize( - rows: readonly T[], - totalsOf: (row: T) => CostTotals, - keyOf: (row: T) => string -): T[] { - const weight = (row: T): number => { - const totals = totalsOf(row); - return totals.costMicroUsd ?? tokensOf(totals); - }; - return [...rows].sort( - (left, right) => weight(right) - weight(left) || keyOf(left).localeCompare(keyOf(right)) - ); -} - -// Second precision, no milliseconds - the same spelling the journal hook writes to a line's -// `at` field, so a row's `startedAt` string-matches the line it opened on. `startMs` is parsed -// from one such value, so this only strips the ".000" `toISOString` would append. -export function isoSecondsFromMs(ms: number): string { - return new Date(ms).toISOString().replace(/\.\d{3}Z$/u, "Z"); -} diff --git a/cli/src/contexts/telemetry/domain/session-anchor.ts b/cli/src/contexts/telemetry/domain/session-anchor.ts deleted file mode 100644 index 3647905d7..000000000 --- a/cli/src/contexts/telemetry/domain/session-anchor.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Which environment variable names the session running this process. Codex first: a Codex - * process nested inside a Claude Code session inherits `CLAUDE_CODE_SESSION_ID`, a false - * anchor naming the enclosing session. The order is what the plugin's own `session-anchor.cjs` - * measured before it was deleted; no other host was probed, so no other host reads an anchor. */ -export function resolveSessionAnchor(env: NodeJS.ProcessEnv): string | undefined { - return env.CODEX_THREAD_ID || env.CLAUDE_CODE_SESSION_ID; -} diff --git a/cli/src/contexts/telemetry/domain/session-project.ts b/cli/src/contexts/telemetry/domain/session-project.ts deleted file mode 100644 index e34c33c53..000000000 --- a/cli/src/contexts/telemetry/domain/session-project.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { RunJournal } from "./ports/run-journal-reader.js"; - -/** Which of `session_start`'s two fields named the project: `project_id` is a directory name - * that collides across machines, so a consumer has to be told which one it got. */ -export type ProjectField = "project_id" | "project_remote"; - -export interface SessionProject { - readonly projectId: string; - readonly projectField: ProjectField; -} - -/** A stored record's project, and which field named it — the pair, never the value alone. */ -export interface RecordProjectClaim { - readonly project_id?: string; - readonly project_field?: string; -} - -/** `project_remote` wins when present: one value for every checkout of a repository, where - * `project_id` carries no such guarantee. Neither field named answers `null`, never a guess. */ -export function resolveSessionProject(journal: RunJournal | null): SessionProject | null { - const session = journal?.session; - if (!session) return null; - if (session.project_remote !== undefined && session.project_remote !== "") { - return { projectId: session.project_remote, projectField: "project_remote" }; - } - if (session.project_id !== undefined && session.project_id !== "") { - return { projectId: session.project_id, projectField: "project_id" }; - } - return null; -} - -function projectOfRecord(record: RecordProjectClaim): SessionProject | null { - const { project_id: projectId, project_field: projectField } = record; - if (projectId === undefined || projectId === "") return null; - if (projectField !== "project_id" && projectField !== "project_remote") return null; - return { projectId, projectField }; -} - -function sameProject(left: SessionProject, right: SessionProject): boolean { - return left.projectField === right.projectField && left.projectId === right.projectId; -} - -/** The project the stored records put an anchored session under, when it is not one of `here`'s. - * Compares only values named by the same field — `project_id` is a directory name and - * `project_remote` a URL, so across the two a match reads as a mismatch — and one record naming - * a project in `here` settles the session as this project's, whatever the others say. */ -export function anchorProjectElsewhere( - here: readonly SessionProject[], - anchorRecords: readonly RecordProjectClaim[] -): string | null { - const comparable = anchorRecords - .map(projectOfRecord) - .filter((project): project is SessionProject => project !== null) - .filter((project) => here.some((mine) => mine.projectField === project.projectField)); - if (comparable.length === 0) return null; - if (comparable.some((project) => here.some((mine) => sameProject(mine, project)))) return null; - return [...new Set(comparable.map((project) => project.projectId))].join(", "); -} diff --git a/cli/src/contexts/telemetry/domain/skill-name.ts b/cli/src/contexts/telemetry/domain/skill-name.ts deleted file mode 100644 index c7b5c875c..000000000 --- a/cli/src/contexts/telemetry/domain/skill-name.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** Two hosts spell one skill differently — `aidd-dev:01-plan` where the argument is passed, the - * bare `01-plan` from a `SKILL.md` path — so an exact comparison would leave the interval open. - * Two qualified spellings that disagree never fold; a bare one closes whichever skill is open. */ -export function namesTheSameSkill(one: string, other: string): boolean { - if (one === other) return true; - const oneQualified = one.includes(":"); - const otherQualified = other.includes(":"); - if (oneQualified && otherQualified) return false; - return bareSkillName(one) === bareSkillName(other); -} - -/** The name with any `plugin:` prefix dropped. The separator is the first colon, the one - * shape this domain has: `plugin:skill`, or a bare `skill`. */ -function bareSkillName(skill: string): string { - const separator = skill.indexOf(":"); - return separator === -1 ? skill : skill.slice(separator + 1); -} diff --git a/cli/src/contexts/telemetry/domain/step-attribution.ts b/cli/src/contexts/telemetry/domain/step-attribution.ts deleted file mode 100644 index 839b04cbc..000000000 --- a/cli/src/contexts/telemetry/domain/step-attribution.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { buildFlowIntervals, ORCHESTRATING_SKILLS } from "./flow-attribution.js"; -import { - buildClosedIntervals, - type ClosedInterval, - type IntervalClosure, -} from "./journal-intervals.js"; -import type { - RunJournal, - RunJournalBoundary, - RunJournalFileWritten, - RunJournalStepStart, - RunJournalTaskDeclared, -} from "./ports/run-journal-reader.js"; -import { namesTheSameSkill } from "./skill-name.js"; - -/** How a record's step came to be known: a name the tool stated and one taken from an interval - * are different claims. `unattributed` is a value, never an absent field, which would read as - * "no step ran" — an assertion no transcript or journal supports. */ -export type StepAttributionSource = - | "tool-stated" - | "prompt-matched" - | "journal-interval" - | "unattributed"; - -/** Strongest first, and fixed: ordering them by how much of a period each accounted for - * would make the order itself a measurement, which a stable contract must not do. */ -export const STEP_ATTRIBUTION_SOURCES: readonly StepAttributionSource[] = [ - "tool-stated", - "prompt-matched", - "journal-interval", - "unattributed", -]; - -export interface StepAttribution { - readonly source: StepAttributionSource; - readonly step?: string; -} - -const UNATTRIBUTED: StepAttribution = { source: "unattributed" }; - -/** One `step_start`, closed by a `step_end` naming that same skill or by the next - * `step_start`, and - unclosed - capped at the journal's own last witnessed moment; `endMs` - * is exclusive. A `turn_end` is a pause, not the end of a step, and never closes one. Capped - * rather than left open because one session's `vendor_id` can span weeks of unrelated work. */ -export interface StepInterval extends ClosedInterval { - readonly skill: string; - /** Whether `endMs` is a moment the journal witnessed or the cap standing in for one it - * never did - `answersFor` reads it, and it is why the cap above is safe to apply. */ - readonly closedBy: IntervalClosure; -} - -/** Journal lines in, closed intervals out. An orchestrating skill's `step_start` is - * `buildFlowIntervals`'s to open, not this walk's; a `step_end` matches its opener through - * `namesTheSameSkill` and never `===`, since a host may write the skill's bare directory name - * where the end the skill echoes carries its plugin. */ -function buildInvokedStepIntervals( - journal: RunJournal, - periodEndMs: number | undefined -): readonly StepInterval[] { - return buildClosedIntervals< - RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, - RunJournalStepStart, - StepInterval - >( - [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], - periodEndMs, - (boundary): boundary is RunJournalStepStart => - boundary.type === "step_start" && !ORCHESTRATING_SKILLS.has(boundary.skill), - // Any `step_start` closes one of these, an orchestrating one included: a session that - // starts orchestrating is no longer running the plain skill it was running before. - (boundary, opener) => - boundary.type === "step_start" || - (boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill)), - (opener, startMs, endMs, closedBy) => ({ skill: opener.skill, startMs, endMs, closedBy }) - ); -} - -/** Two walks over the same lines: the orchestrating half **is** `buildFlowIntervals`, so an - * invoked step never closes the orchestration that invoked it. Which skills orchestrate is - * `ORCHESTRATING_SKILLS`'s declaration - nesting and sequence produce the identical journal, - * so nothing read off the boundaries alone can separate them. */ -export function buildStepIntervals( - journal: RunJournal, - periodEndMs?: number -): readonly StepInterval[] { - return [ - ...buildFlowIntervals(journal, periodEndMs), - ...buildInvokedStepIntervals(journal, periodEndMs), - ]; -} - -/** The most specific interval a moment falls in: the latest to have opened, and among equals - * the first to close. Order in the array decides nothing - the two walks that build these run - * separately, so reading the first match would answer differently per run order. */ -function innermostOf(intervals: readonly StepInterval[]): StepInterval | undefined { - let best: StepInterval | undefined; - for (const interval of intervals) { - if ( - best === undefined || - interval.startMs > best.startMs || - (interval.startMs === best.startMs && interval.endMs < best.endMs) - ) { - best = interval; - } - } - return best; -} - -/** Whether an interval nothing closed sits inside another that nothing closed either. Both end - * at the same capped moment, so containment reduces to which opened first; the enclosing one - * wins because the inner one's extent rests on no evidence at all. */ -function enclosedByAnotherUnclosed( - covering: readonly StepInterval[], - interval: StepInterval -): boolean { - if (interval.closedBy !== "journal-end") return false; - return covering.some( - (other) => other.closedBy === "journal-end" && other.startMs < interval.startMs - ); -} - -/** The interval that answers for a moment: the innermost one covering it, *except* that an - * interval nothing ever closed yields to one that encloses it and was never closed either. An - * unclosed extent is a bound, not a measurement, so a step opened shortly before a long - * session goes on working would otherwise be credited with all of it. */ -function answersFor( - intervals: readonly StepInterval[], - momentMs: number -): StepInterval | undefined { - const covering = intervals.filter( - (interval) => momentMs >= interval.startMs && momentMs < interval.endMs - ); - return innermostOf(covering.filter((interval) => !enclosedByAnotherUnclosed(covering, interval))); -} - -/** A record's moment inside one interval takes that interval's skill. One with no moment, or - * earlier than every interval, is unattributed — never folded into the first step. */ -export function attributeMoment( - intervals: readonly StepInterval[], - momentIso: string | undefined -): StepAttribution { - if (momentIso === undefined) return UNATTRIBUTED; - const momentMs = Date.parse(momentIso); - if (Number.isNaN(momentMs)) return UNATTRIBUTED; - const hit = answersFor(intervals, momentMs); - return hit ? { source: "journal-interval", step: hit.skill } : UNATTRIBUTED; -} diff --git a/cli/src/contexts/telemetry/domain/task-attribution.ts b/cli/src/contexts/telemetry/domain/task-attribution.ts deleted file mode 100644 index 0415d5c65..000000000 --- a/cli/src/contexts/telemetry/domain/task-attribution.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { buildClosedIntervals, type ClosedInterval } from "./journal-intervals.js"; -import type { - RunJournal, - RunJournalBoundary, - RunJournalFileWritten, - RunJournalTaskDeclared, -} from "./ports/run-journal-reader.js"; -import { taskIdentityFromWrittenPath } from "./task-identity.js"; - -/** How a record's task came to be known: a flow declared it, or this layer inferred it from a - * written file. No "unattributed": a record matching neither route `taskMembershipFor` names - * is not in the report at all. */ -export type TaskAttributionSource = "declared" | "inferred"; - -export const TASK_ATTRIBUTION_SOURCES: readonly TaskAttributionSource[] = ["declared", "inferred"]; - -/** One declared interval, closed by a later declaration - or, unclosed, capped at the - * journal's own last recorded moment. A `turn_end` is a pause, not a change of subject: it - * witnesses that moment but never closes. Never open-ended: no tool exposes when a flow leaves - * a ticket, so a boundless interval would keep crediting the first ticket ever named. */ -export interface TaskInterval extends ClosedInterval { - readonly path: string; -} - -/** Journal lines in, bounded intervals out; each `task_declared` closes at the next. Unclosed, - * it is capped at the merged list's last moment, a written file included, never `[t, t)`, and - * `periodEndMs` caps what a damaged clock could widen that to. A declaration whose `path` - * resolves to no identity still closes the previous one, which would otherwise widen. */ -export function buildTaskIntervals( - journal: RunJournal, - periodEndMs?: number -): readonly TaskInterval[] { - return buildClosedIntervals< - RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, - RunJournalTaskDeclared, - TaskInterval - >( - [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], - periodEndMs, - (boundary): boundary is RunJournalTaskDeclared => boundary.type === "task_declared", - // Only a later declaration closes one: a `turn_end` witnesses its moment, it never ends - // the subject. - () => false, - (opener, startMs, endMs) => - taskIdentityFromWrittenPath(opener.path) === null - ? null - : { path: opener.path, startMs, endMs } - ); -} - -/** Why a record belongs to no task - one distinct fact per reason, each acted on differently. - * `"no-journal"` is `taskRowOf`'s to answer, from its own map; the four below are this one's. - * - * - `"no-journal"`: no *usable* journal reached this session - none read, or one unusable. - * - `"no-declaration"`: no usable declared interval - none written, or none placeable in time. - * - `"precedes-journal"`: older than the journal's earliest moment, so nothing could cover it. - * - `"precedes-declaration"`: a record before the session's very first declaration. - * - `"journal-silent"`: declared coverage ran out before this moment; an undated record too. */ -export type TaskUnattributedReason = - | "no-journal" - | "precedes-journal" - | "no-declaration" - | "precedes-declaration" - | "journal-silent"; - -/** Fixed and always in this order, never ordered by how much of a period each accounted for, - * so a reader comparing two periods finds the same list. */ -export const TASK_UNATTRIBUTED_REASONS: readonly TaskUnattributedReason[] = [ - "no-journal", - "precedes-journal", - "no-declaration", - "precedes-declaration", - "journal-silent", -]; - -/** `journalFirstWitnessedMs` is the earliest moment this session's journal witnessed, absent - * for one carrying no readable moment: absent is no coverage claim, never a `0`. */ -export function taskUnattributedReason( - intervals: readonly TaskInterval[], - momentIso: string | undefined, - journalFirstWitnessedMs?: number -): TaskUnattributedReason { - const momentMs = momentIso === undefined ? Number.NaN : Date.parse(momentIso); - if ( - !Number.isNaN(momentMs) && - journalFirstWitnessedMs !== undefined && - momentMs < journalFirstWitnessedMs - ) { - return "precedes-journal"; - } - if (intervals.length === 0) return "no-declaration"; - if (Number.isNaN(momentMs)) return "journal-silent"; - const somethingDeclaredAfter = intervals.some((interval) => interval.startMs > momentMs); - return somethingDeclaredAfter ? "precedes-declaration" : "journal-silent"; -} diff --git a/cli/src/contexts/telemetry/domain/task-backlog-link.ts b/cli/src/contexts/telemetry/domain/task-backlog-link.ts deleted file mode 100644 index 9920cc750..000000000 --- a/cli/src/contexts/telemetry/domain/task-backlog-link.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { TaskIdentity } from "./task-identity.js"; - -/** `backlog-link.json`, a task folder's declaration of which backlog item it delivers. It - * carries the one fact nothing else in the repository holds, and nothing the backlog artefact - * itself already holds — no type, no originating ticket — so the two cannot disagree. */ -export interface TaskBacklogLink { - /** The backlog item, on whatever support it lives: a forge reference where a ticket provider - * holds the backlog, a project-relative path where Markdown does. Never resolved to a title - * or a state here; that is a destination's work. */ - readonly backlog: string; - /** ISO 8601. Provenance, not status: judging a wrong link means knowing which act made it. */ - readonly writtenAt: string; - /** A skill's own name (`"aidd-pm:04-spec"`), or `"hand"` for a person who corrected it - * directly — beside `writtenAt`, what traces a wrong link back to the act that made it. */ - readonly writtenBy: string; -} - -/** Three states, never two. `"declared"` and `"none"` are both normal states of a task; - * `"unreadable"` — the file exists and could not be parsed — differs in kind and must never - * print as `"none"`, so one bad folder's damage stays visible on its own row. */ -export type TaskBacklogDeclaration = - | { readonly kind: "declared"; readonly link: TaskBacklogLink } - | { readonly kind: "none" } - | { readonly kind: "unreadable" }; - -/** The project-relative folder an identity resolves to. A single-file task has no folder to - * hold a declaration, so a reader asking that path finds none — the same answer an ordinary - * folder without one gives, never a special case. */ -export function taskFolderPathFromIdentity(identity: TaskIdentity): string { - return `aidd_docs/tasks/${identity}/`; -} diff --git a/cli/src/contexts/telemetry/domain/task-identity.ts b/cli/src/contexts/telemetry/domain/task-identity.ts deleted file mode 100644 index f929caee5..000000000 --- a/cli/src/contexts/telemetry/domain/task-identity.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** A task's identity is derived here rather than journalled, so it can be revised over every - * past session. Two shapes are both real tasks — a folder of files and a single `.md` file — - * and matching only the folder would leave half of them unattributable. The anchoring mirrors - * `file-writes.cjs`'s own gate: a path refused here never produced a line to read. */ -const TASK_FOLDER_PATTERN = /^aidd_docs\/tasks\/(\d{4}_\d{2})\/([^/]+)\//u; -const TASK_FILE_PATTERN = /^aidd_docs\/tasks\/(\d{4}_\d{2})\/([^/]+)\.md$/u; - -/** A task's month and its own name. A folder task and a single-file task of the same name - * resolve to one identity, so a task that grew into a folder does not read as two. */ -export type TaskIdentity = string; - -/** The task a written path belongs to, answering what the path says and never what is on - * disk. The path must be repository-relative and `/`-separated, as the journal writes it on - * every platform. A segment check, not a substring one: the hooks allow `..` inside a name, - * so only a segment that is exactly `..` climbs out and is refused. */ -export function taskIdentityFromWrittenPath(writtenPath: string): TaskIdentity | null { - if (writtenPath.split("/").includes("..")) return null; - const match = TASK_FOLDER_PATTERN.exec(writtenPath) ?? TASK_FILE_PATTERN.exec(writtenPath); - if (!match) return null; - const [, month, name] = match; - return month !== undefined && name !== undefined ? `${month}/${name}` : null; -} - -/** In first-seen order, without repeats: a session that wrote into two tasks belongs to both, - * and one that wrote into none is still fully reportable by period. */ -export function taskIdentitiesFromWrittenPaths( - writtenPaths: readonly string[] -): readonly TaskIdentity[] { - const seen = new Set(); - const identities: TaskIdentity[] = []; - for (const writtenPath of writtenPaths) { - const identity = taskIdentityFromWrittenPath(writtenPath); - if (identity !== null && !seen.has(identity)) { - seen.add(identity); - identities.push(identity); - } - } - return identities; -} diff --git a/cli/src/contexts/telemetry/domain/telemetry-claim.ts b/cli/src/contexts/telemetry/domain/telemetry-claim.ts deleted file mode 100644 index e10e34ef6..000000000 --- a/cli/src/contexts/telemetry/domain/telemetry-claim.ts +++ /dev/null @@ -1,484 +0,0 @@ -import type { AiToolId } from "../../../kernel/tool.js"; -import type { StepAttributionSource } from "./step-attribution.js"; - -/** Four independently verifiable claims about the measurement chain, each answered from what was - * actually read, never inferred from the others. Ported from the plugin's own `diagnose.cjs`, - * whose route is named here rather than left behind a pointer to a file no reader can open: - * `hook fired` -> `session journalled` -> `tool files readable` -> `records join`. */ -export type TelemetryClaimId = - | "hook-fired" - | "session-journalled" - | "tool-files-readable" - | "records-join"; - -export type TelemetryClaimVerdict = "ok" | "fail" | "unknown"; - -/** The reasons `noRunFileClaim` can land the first claim on — one absence, told apart by what is - * known about it, never guessed. Its own union: `TelemetryClaimReason` composes it in, so a - * sixth member forces every exhaustive `Record` keyed on it to name one or fail to compile. */ -export type NoRunFileReason = - | "recorder-declared-nowhere" - | "recorder-declared-not-yet-fired" - | "recorder-declaration-unreadable" - | "anchorless-run-file" - | "journal-in-another-schema"; - -/** The closed set of reasons a claim can land on a verdict. One claim's `fail` has several - * distinct causes — "no run file" and "untrusted hook" both fail `hook-fired` — so a caller - * switches on the reason, never on the verdict alone. */ -export type TelemetryClaimReason = - | "session-anchored" - | "untrusted-codex-hook" - | NoRunFileReason - | "unrecognised-payload" - | "session-left-no-run-file" - | "anchor-in-another-project" - | "no-session-anchor" - | "turn-closed" - | "only-session-start" - | "no-run-file-to-read" - | "session-found" - | "no-session-found-for-any-tool" - | "no-session-named" - | "records-joined" - | "all-unattributed" - | "no-record-to-join" - | "no-join-material"; - -export interface TelemetryClaim { - readonly claim: TelemetryClaimId; - readonly verdict: TelemetryClaimVerdict; - readonly reason: TelemetryClaimReason; - readonly detail: string; -} - -/** One journalled session, the shape `claimHookFired`/`claimSessionJournalled` need from it — - * a fuller run journal carries nothing these claims read, so it stays out of this shape. */ -export interface TelemetryClaimJournal { - readonly vendorId?: string; - readonly sessionStartAt?: string; - readonly turnClosed: boolean; -} - -/** One covered tool's attempt to read one journalled session's files. `records` carries only the - * step attribution each resolved to, never counters this diagnostic would be repeating. */ -export interface TelemetryClaimToolRead { - readonly tool: AiToolId; - readonly sessionFound: boolean; - readonly hasIntervals: boolean; - readonly records: readonly { - readonly stepAttribution: StepAttributionSource; - }[]; - readonly error?: string; -} - -/** Whether Codex has trusted this plugin's hook — `undefined` when there is no trust gate to - * consult (every host but Codex, or a Codex session whose anchor was never resolved). - * `readable: false` covers everything short of the config file opening as text. */ -export interface TelemetryCodexHookTrust { - readonly readable: boolean; - readonly trusted?: boolean; - readonly configPath?: string; - readonly reason?: string; -} - -export interface TelemetryEvidence { - readonly journals: readonly TelemetryClaimJournal[]; - readonly toolReads: readonly TelemetryClaimToolRead[]; - readonly runsDirLabel: string; - readonly currentSessionId?: string; - readonly unrecognisedPayloadAt?: string; - readonly hookTrust?: TelemetryCodexHookTrust; - /** The project the stored records put the anchored session under, present only when that is - * decidably not this one: an anchor is inherited by any process nested inside a session, - * whatever directory it runs in. */ - readonly anchorInAnotherProject?: string; - /** Whether the recorder is declared anywhere this build checks — read the same way - * `TelemetrySetup`'s own `recorderDeclaration` is, so the two cannot disagree. Never proof - * the hook will fire: a declaration can be silently dropped. */ - readonly recorderDeclared: boolean; - /** Whether every location `readRecorderDeclaration` checked was readable — `false` only when - * `recorderDeclared` is `false` *and* a checked location exists but could not be read. A - * damaged declaring file is not the same absence as one declaring nothing: collapsing them - * grades a healthy install `FAIL` for a cause it does not have. */ - readonly recorderDeclarationReadable: boolean; - /** The schema stated by every run file the journal reader refused. Carried separately from - * `journals` because a refused file is absent there while present on disk, and without it the - * claim below falls through to a branch that is false about it. */ - readonly foreignSchemaVersions: readonly number[]; -} - -function sessionJournalsOf( - journals: readonly TelemetryClaimJournal[] -): readonly TelemetryClaimJournal[] { - return journals.filter((journal) => journal.vendorId !== undefined); -} - -function latestSessionStart(journals: readonly TelemetryClaimJournal[]): string { - const starts = journals - .map((journal) => journal.sessionStartAt) - .filter((at): at is string => at !== undefined) - .sort(); - return starts[starts.length - 1] ?? "an unreadable session_start"; -} - -function firedForSession(journals: readonly TelemetryClaimJournal[], sessionId: string): boolean { - return journals.some((journal) => journal.vendorId === sessionId); -} - -function trustExplainsAbsence(hookTrust: TelemetryCodexHookTrust | undefined): boolean { - return Boolean(hookTrust?.readable && !hookTrust.trusted); -} - -function untrustedHookClaim(hookTrust: TelemetryCodexHookTrust): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "fail", - reason: "untrusted-codex-hook", - detail: - "Codex has not trusted this plugin's hook — no trusted_hash for " + - `hooks/hooks.json:session_start in ${hookTrust.configPath}. Approve it interactively ` + - "once, or pass --dangerously-bypass-hook-trust to codex exec for a headless run.", - }; -} - -function unreadableTrustSuffix(hookTrust: TelemetryCodexHookTrust | undefined): string { - if (!hookTrust || hookTrust.readable) return ""; - return ` — Codex's own hook trust state could not be read either (${hookTrust.reason}), so this may be the same cause`; -} - -// The one absence, two causes this claim exists to tell apart: a recorder never declared -// anywhere this build reads has nothing that could have written a run file, which is a -// failure worth naming; a recorder that IS declared may simply not have run yet — nothing to -// evaluate, and its detail says so without promising the declaration will actually fire. -function recorderDeclaredNotYetFiredClaim(runsDirLabel: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "unknown", - reason: "recorder-declared-not-yet-fired", - detail: - `no run file in ${runsDirLabel} yet — nothing to evaluate. The recorder is declared, ` + - "but a declaration is not proof it will fire: a headless run can silently drop it " + - "without ever registering the plugin (see claude-cli-adapter.ts).", - }; -} - -function recorderNotDeclaredClaim( - runsDirLabel: string, - hookTrust: TelemetryCodexHookTrust | undefined -): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "fail", - reason: "recorder-declared-nowhere", - detail: - `no run file in ${runsDirLabel} — the hook has never been observed firing, and the ` + - `recorder is declared nowhere this build checks${unreadableTrustSuffix(hookTrust)}`, - }; -} - -// A location that cannot be read says so and costs only itself: this is not proof the -// recorder is missing, only that whether it is declared could not be determined. Grading -// `FAIL` off an unreadable file tells a healthy install it is broken. -function recorderDeclarationUnreadableClaim(runsDirLabel: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "unknown", - reason: "recorder-declaration-unreadable", - detail: - `no run file in ${runsDirLabel} yet, and whether the recorder is declared could not ` + - "be read — see recorder declared, above, for which location. A damaged declaring " + - "file is not the same absence as one that never declared the recorder.", - }; -} - -// A run file existing at all is direct evidence the recorder did something, whatever the -// declaration says. Unconditional on `recorderDeclared`: a hooks block registering PostToolUse -// but never SessionStart produces exactly this file while reading "declared nowhere", so gating -// on it would print "no run file" about a file that demonstrably exists. -function anchorlessRunFileClaim(runsDirLabel: string, fileCount: number): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "fail", - reason: "anchorless-run-file", - detail: - `${fileCount} run file(s) in ${runsDirLabel}, but none carry a readable session_start ` + - "to anchor them — a torn write, or a hooks block that registers another event " + - "without SessionStart, never a hook that has not fired", - }; -} - -// The schema a journal states is the writer's own statement about its shape, so a build that -// does not read that schema knows exactly one thing about the file: not what its lines mean. -// Ahead of `anchorlessRunFileClaim`, whose "none carry a readable session_start" is a claim -// about contents this build has just said it cannot make. -function foreignSchemaClaim(runsDirLabel: string, stated: readonly number[]): TelemetryClaim { - const versions = [...new Set(stated)].sort((left, right) => left - right).join(", "); - return { - claim: "hook-fired", - verdict: "fail", - reason: "journal-in-another-schema", - detail: - `${stated.length} run file(s) in ${runsDirLabel} written under a schema this build does ` + - `not read (${versions}) — a journal from another version of the plugin, never a hook ` + - "that did not fire", - }; -} - -function noRunFileClaim( - runsDirLabel: string, - hookTrust: TelemetryCodexHookTrust | undefined, - recorderDeclared: boolean, - recorderDeclarationReadable: boolean, - anchorlessFileCount: number, - foreignSchemaVersions: readonly number[] -): TelemetryClaim { - if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); - if (foreignSchemaVersions.length > 0) { - return foreignSchemaClaim(runsDirLabel, foreignSchemaVersions); - } - if (anchorlessFileCount > 0) return anchorlessRunFileClaim(runsDirLabel, anchorlessFileCount); - if (!recorderDeclarationReadable) return recorderDeclarationUnreadableClaim(runsDirLabel); - if (recorderDeclared) return recorderDeclaredNotYetFiredClaim(runsDirLabel); - return recorderNotDeclaredClaim(runsDirLabel, hookTrust); -} - -function unrecognisedPayloadClaim(at: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "fail", - reason: "unrecognised-payload", - detail: `a payload arrived and matched no known host at ${at} — this tool is not recognised, not a hook that never ran`, - }; -} - -function noAnchorClaim(journals: readonly TelemetryClaimJournal[], latest: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "unknown", - reason: "no-session-anchor", - detail: `${journals.length} run file(s), most recent session_start ${latest} — no session anchor available to tell whether this session's hook fired`, - }; -} - -function anotherProjectClaim(project: string, latest: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "unknown", - reason: "anchor-in-another-project", - detail: - `this session belongs to ${project}, not to this project — its stored figures name that ` + - `project, and nothing here is evidence about its hook. The newest run file here is from ` + - latest, - }; -} - -function sessionAnchoredClaim( - journals: readonly TelemetryClaimJournal[], - latest: string, - currentSessionId: string, - hookTrust: TelemetryCodexHookTrust | undefined, - anchorInAnotherProject: string | undefined -): TelemetryClaim { - if (!firedForSession(journals, currentSessionId)) { - // Before the trust gate: local trust state says nothing about another project's session. - if (anchorInAnotherProject !== undefined) { - return anotherProjectClaim(anchorInAnotherProject, latest); - } - if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); - return { - claim: "hook-fired", - verdict: "fail", - reason: "session-left-no-run-file", - detail: `this session left no run file — the newest one is from ${latest}${unreadableTrustSuffix(hookTrust)}`, - }; - } - return { - claim: "hook-fired", - verdict: "ok", - reason: "session-anchored", - detail: `${journals.length} run file(s), most recent session_start ${latest}`, - }; -} - -function noSessionJournalClaim(evidence: TelemetryEvidence): TelemetryClaim { - const { journals, runsDirLabel, unrecognisedPayloadAt, hookTrust } = evidence; - if (unrecognisedPayloadAt !== undefined) return unrecognisedPayloadClaim(unrecognisedPayloadAt); - return noRunFileClaim( - runsDirLabel, - hookTrust, - evidence.recorderDeclared, - evidence.recorderDeclarationReadable, - journals.length, - evidence.foreignSchemaVersions - ); -} - -function claimHookFired(evidence: TelemetryEvidence): TelemetryClaim { - const sessionJournals = sessionJournalsOf(evidence.journals); - if (sessionJournals.length === 0) return noSessionJournalClaim(evidence); - const latest = latestSessionStart(sessionJournals); - if (evidence.currentSessionId === undefined) return noAnchorClaim(sessionJournals, latest); - return sessionAnchoredClaim( - sessionJournals, - latest, - evidence.currentSessionId, - evidence.hookTrust, - evidence.anchorInAnotherProject - ); -} - -function claimSessionJournalled(journals: readonly TelemetryClaimJournal[]): TelemetryClaim { - const sessionJournals = sessionJournalsOf(journals); - if (sessionJournals.length === 0) { - return { - claim: "session-journalled", - verdict: "unknown", - reason: "no-run-file-to-read", - detail: "no run file to read", - }; - } - const closed = sessionJournals.filter((journal) => journal.turnClosed); - if (closed.length === 0) { - return { - claim: "session-journalled", - verdict: "fail", - reason: "only-session-start", - detail: `${sessionJournals.length} run file(s), all carrying only session_start — nothing closed the turn`, - }; - } - return { - claim: "session-journalled", - verdict: "ok", - reason: "turn-closed", - detail: `${closed.length} of ${sessionJournals.length} run file(s) carry more than session_start`, - }; -} - -interface ToolTally { - attempted: number; - found: number; - errors: string[]; -} - -function tallyByTool(toolReads: readonly TelemetryClaimToolRead[]): Map { - const byTool = new Map(); - for (const read of toolReads) { - const entry = byTool.get(read.tool) ?? { attempted: 0, found: 0, errors: [] }; - entry.attempted += 1; - entry.found += read.sessionFound ? 1 : 0; - if (read.error !== undefined) entry.errors.push(read.error); - byTool.set(read.tool, entry); - } - return byTool; -} - -function readableSummary(toolReads: readonly TelemetryClaimToolRead[]): string { - return [...tallyByTool(toolReads).entries()] - .map(([tool, tally]) => { - const failed = tally.errors.length > 0 ? `, ${tally.errors.length} could not be read` : ""; - return `${tool}: ${tally.found} of ${tally.attempted} session(s) read${failed}`; - }) - .join("; "); -} - -function errorNote(toolReads: readonly TelemetryClaimToolRead[]): string { - const errors = toolReads - .map((read) => read.error) - .filter((error): error is string => error !== undefined); - return errors.length === 0 - ? "" - : ` — ${errors.length} read attempt(s) failed: ${errors[errors.length - 1]}`; -} - -function claimToolsReadable( - journals: readonly TelemetryClaimJournal[], - toolReads: readonly TelemetryClaimToolRead[] -): TelemetryClaim { - const sessionIds = [...new Set(sessionJournalsOf(journals).map((journal) => journal.vendorId))]; - if (sessionIds.length === 0) { - return { - claim: "tool-files-readable", - verdict: "unknown", - reason: "no-session-named", - detail: "no session named by the journal", - }; - } - if (!toolReads.some((read) => read.sessionFound)) { - const tools = [...new Set(toolReads.map((read) => read.tool))].join(", "); - return { - claim: "tool-files-readable", - verdict: "fail", - reason: "no-session-found-for-any-tool", - detail: `no session found for any journalled session, across every covered tool (${tools}) — while the journal names ${sessionIds.join(", ")}${errorNote(toolReads)}`, - }; - } - return { - claim: "tool-files-readable", - verdict: "ok", - reason: "session-found", - detail: readableSummary(toolReads), - }; -} - -function hasJoinMaterial( - toolReads: readonly TelemetryClaimToolRead[], - records: readonly { readonly stepAttribution: StepAttributionSource }[] -): boolean { - return ( - toolReads.some((read) => read.hasIntervals) || - records.some((record) => record.stepAttribution === "tool-stated") - ); -} - -function joinedVerdict( - records: readonly { readonly stepAttribution: StepAttributionSource }[] -): TelemetryClaim { - const joined = records.filter((record) => record.stepAttribution !== "unattributed"); - if (joined.length === 0) { - return { - claim: "records-join", - verdict: "fail", - reason: "all-unattributed", - detail: `${records.length} record(s) found, joined: 0 — every record unattributed`, - }; - } - const rest = records.length - joined.length; - return { - claim: "records-join", - verdict: "ok", - reason: "records-joined", - detail: `${joined.length} of ${records.length} record(s) joined a step, ${rest} unattributed`, - }; -} - -function claimRecordsJoin(toolReads: readonly TelemetryClaimToolRead[]): TelemetryClaim { - const records = toolReads.flatMap((read) => read.records); - if (records.length === 0) { - return { - claim: "records-join", - verdict: "unknown", - reason: "no-record-to-join", - detail: "no record read to join", - }; - } - if (!hasJoinMaterial(toolReads, records)) { - return { - claim: "records-join", - verdict: "unknown", - reason: "no-join-material", - detail: "no step interval and no tool-stated step — see session journalled", - }; - } - return joinedVerdict(records); -} - -/** The four claims, always in this order, and never a fifth line that summarises them. */ -export function diagnoseTelemetryClaims(evidence: TelemetryEvidence): readonly TelemetryClaim[] { - return [ - claimHookFired(evidence), - claimSessionJournalled(evidence.journals), - claimToolsReadable(evidence.journals, evidence.toolReads), - claimRecordsJoin(evidence.toolReads), - ]; -} diff --git a/cli/src/contexts/telemetry/domain/telemetry-export-leftover.ts b/cli/src/contexts/telemetry/domain/telemetry-export-leftover.ts deleted file mode 100644 index 88e9673df..000000000 --- a/cli/src/contexts/telemetry/domain/telemetry-export-leftover.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { asPlainObject } from "../../../kernel/reading/plain-object.js"; - -/** The `env` keys a since-removed `telemetry endpoint` command wrote into a Claude Code - * settings file. Detection only: nothing in this system writes them any more, and a settings - * file still carrying them keeps exporting, so naming them is the only remedy left. */ -export const CLAUDE_TELEMETRY_EXPORT_ENV_KEYS = [ - "CLAUDE_CODE_ENABLE_TELEMETRY", - "OTEL_METRICS_EXPORTER", - "OTEL_LOGS_EXPORTER", - "OTEL_EXPORTER_OTLP_PROTOCOL", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_METRIC_EXPORT_INTERVAL", - "OTEL_RESOURCE_ATTRIBUTES", -] as const; - -/** One settings file and which of the keys it carries, so exactly those can be removed rather - * than the whole `env` block. */ -export interface TelemetryExportLeftover { - readonly path: string; - readonly keys: readonly string[]; -} - -/** Never throws: an absent or malformed file has no keys this can find, which is not the same - * claim as "clean" but is the only one available here. */ -export function findLeftoverExportKeys(content: string | null): readonly string[] { - if (content === null) return []; - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch { - return []; - } - const env = asPlainObject(asPlainObject(parsed)?.env); - if (env === null) return []; - return CLAUDE_TELEMETRY_EXPORT_ENV_KEYS.filter((key) => key in env); -} diff --git a/cli/src/contexts/telemetry/domain/telemetry-removal.ts b/cli/src/contexts/telemetry/domain/telemetry-removal.ts deleted file mode 100644 index 47f74c69b..000000000 --- a/cli/src/contexts/telemetry/domain/telemetry-removal.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** Every location `aidd telemetry forget` would remove from, resolved exactly once and then - * handed to the removal step rather than re-resolved there: a second resolution could disagree - * with the first, and would delete something the person confirming was never shown. `scope` - * separates one project's journal from the machine-wide sink and identity on the type itself. */ - -export interface TelemetryProjectJournalRemoval { - readonly scope: "project"; - /** The run journal's own directory, as `RunJournalReader.runsDir` resolved it. */ - readonly path: string; - /** By name, never derived from parsing: a run file too damaged to parse still has a name - * `readdir` can see, so it is still listed and still removed. */ - readonly runFileNames: readonly string[]; -} - -export interface TelemetryMachineSinkRemoval { - readonly scope: "machine"; - /** As `TelemetrySink.rootDir` resolved it: every project measured on this machine, never - * one project alone. */ - readonly path: string; - /** By name — a day file's content is never opened, so a damaged one is named like any other. */ - readonly dayFileNames: readonly string[]; -} - -export interface TelemetryMachineIdentityRemoval { - readonly scope: "machine"; - /** This machine's identity file, as `PersonIdentityStore.filePath` resolved it. */ - readonly path: string; - /** True even for a file that exists and cannot be parsed — the file most needing removal. */ - readonly present: boolean; - /** Beside `present` rather than folded into it: a damaged file and an absent one both show - * nothing, but only one is still sitting there. */ - readonly unreadable: boolean; -} - -/** The index and history answer different questions: a file added and never committed is tracked - * while history holds nothing for it, so `git log` separates `"committed"` from `"staged"` — - * whose blob survives this removal and returns on the next commit. `"possible"` is never an - * all-clear; only `"none"`, outside a repository, may say so plainly. */ -export type TelemetryHistoryReading = - | { readonly certainty: "committed"; readonly files: readonly string[] } - | { readonly certainty: "staged"; readonly files: readonly string[] } - | { readonly certainty: "possible" } - | { readonly certainty: "none" }; - -/** Every location a removal would touch and what no removal can touch, resolved together so a - * caller cannot render one without the other. */ -export interface TelemetryRemovalPreview { - readonly journal: TelemetryProjectJournalRemoval; - readonly sink: TelemetryMachineSinkRemoval; - readonly identity: TelemetryMachineIdentityRemoval; - readonly history: TelemetryHistoryReading; -} - -/** Whether to offer removal at all, rather than asking to confirm removing nothing. */ -export function telemetryRemovalIsEmpty(preview: TelemetryRemovalPreview): boolean { - return ( - preview.journal.runFileNames.length === 0 && - preview.sink.dayFileNames.length === 0 && - !preview.identity.present - ); -} diff --git a/cli/src/contexts/telemetry/domain/telemetry-setup.ts b/cli/src/contexts/telemetry/domain/telemetry-setup.ts deleted file mode 100644 index 55427d39c..000000000 --- a/cli/src/contexts/telemetry/domain/telemetry-setup.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { HostRegistrationEntry } from "../../tools/domain/host-plugin-registration.js"; -import { personRefusesTelemetry, TELEMETRY_REFUSAL_VARIABLE } from "./telemetry-switch.js"; - -/** What is already in place before `aidd telemetry check` grades whether anything recorded, - * printed whether or not measurement is on. Every fact names the location it came from and - * carries no count and no figure: the report owns those, and a diagnostic repeating - * quantities can disagree with it. */ -export interface TelemetrySetup { - readonly allowed: TelemetryAllowedSetup; - readonly identity: TelemetryIdentitySetup; - readonly recordsLocation: TelemetryRecordsLocationSetup; - readonly recorderDeclaration: TelemetryRecorderDeclarationSetup; - readonly hostRegistration: TelemetryHostRegistrationSetup; - readonly commitTrailer: TelemetryCommitTrailerSetup; - readonly versions: TelemetryVersionsSetup; -} - -/** Two producers, and only one can be asked directly: the CLI's version is this process's - * own, while the plugin's is a fact about a different program, so it is read back out of the - * journal rather than re-derived by a process that never ran the hook. */ -export interface TelemetryVersionsSetup { - readonly cli: string; - readonly plugin: TelemetryPluginVersionSetup; -} - -/** `"unrecorded"` is a hook that ran and could not name its own build - a plugin copied in - * by hand; `"nothing-journalled"` is a project where nothing has been measured yet and says - * nothing about the plugin. Collapsing them lets "not measured yet" read as "damaged". */ -export type TelemetryPluginVersionSetup = - | { readonly kind: "recorded"; readonly version: string } - | { readonly kind: "unrecorded" } - | { readonly kind: "nothing-journalled" }; - -/** Whether AIDD is allowed to measure this project, and whose decision that is. Mirrors - * `resolveTelemetryEnabled`'s precedence exactly so the two can never disagree: a person's - * own refusal wins unconditionally over whatever the project file says. */ -export interface TelemetryAllowedSetup { - readonly allowed: boolean; - /** `"person-refusal"`: `AIDD_TELEMETRY=0` decided it, whatever the project file holds. - * `"project-switch"`: on, off, absent or unreadable are all that file's decision. */ - readonly decidedBy: "person-refusal" | "project-switch"; - /** The env var name for `"person-refusal"`; the switch file's path for - * `"project-switch"` — where a person would go to change this. */ - readonly location: string; - /** Always `true` for `"person-refusal"`, since reading an env var never fails. `false` - * only for a switch file that exists but could not be read: a damaged file is not the - * same choice as an absent or an explicit one. */ - readonly readable: boolean; -} - -/** The primitives `buildTelemetryAllowedSetup` needs from the project's switch file, never - * the port's own read type: importing that back here would draw a domain model into the - * layer the port exists to abstract away from. */ -export interface TelemetrySwitchFileFacts { - readonly path: string; - readonly enabled: boolean; - readonly readable: boolean; -} - -/** Mirrors `resolveTelemetryEnabled`'s precedence exactly: a person's own refusal wins - * unconditionally, whatever the project file says. Pure, so the precedence itself is - * testable apart from the adapter that reads the switch file. */ -export function buildTelemetryAllowedSetup( - switchFile: TelemetrySwitchFileFacts, - env: NodeJS.ProcessEnv -): TelemetryAllowedSetup { - if (personRefusesTelemetry(env)) { - return { - allowed: false, - decidedBy: "person-refusal", - location: TELEMETRY_REFUSAL_VARIABLE, - readable: true, - }; - } - return { - allowed: switchFile.enabled, - decidedBy: "project-switch", - location: switchFile.path, - readable: switchFile.readable, - }; -} - -/** Whether this person attached their own identifier to what gets read locally, and where - * that file lives. `attached: false, readable: true` is the ordinary "nobody chose" case; - * `readable` is `false` only for a file that exists but is damaged. */ -export interface TelemetryIdentitySetup { - readonly attached: boolean; - readonly path: string; - readonly readable: boolean; -} - -/** The sink's own root directory, resolved by nothing but the adapter that owns it. No - * `readable`: naming a directory never fails, written into or not. */ -export interface TelemetryRecordsLocationSetup { - readonly path: string; -} - -/** Whether the `aidd-telemetry` plugin is declared anywhere this build knows to check. A - * declaration is not proof the hook will fire - a host that never registered the plugin - * drops the entry as orphaned - so whether the host acts on it is - * `TelemetryHostRegistrationSetup` below. */ -export interface TelemetryRecorderDeclarationSetup { - readonly declared: boolean; - /** Where it was found declared — non-empty exactly when `declared` is `true`. */ - readonly declaredAt: readonly string[]; - /** Every location this build knows to check, so a person can go add it there when - * `declared` is `false` — the same set regardless of the outcome. */ - readonly locationsChecked: readonly string[]; - /** Every checked location that exists but could not be read or parsed - the same "a - * damaged file is not a choice" distinction `readable` carries elsewhere, as a list since - * several locations are checked at once. Only meaningful alongside `declared: false`: a - * declaration found at one readable location is real whatever else failed to read. */ - readonly unreadable: readonly string[]; -} - -/** Whether the host will actually load what AIDD installed. Read from AIDD's own manifest, - * never `enabledPlugins`: `mergeEnabledPlugins` skips silently for a plugin recording no - * marketplace and for one that does not resolve, so comparing settings against a registry - * finds both sides absent and reads it as agreement. */ -export interface TelemetryHostRegistrationSetup { - /** One per plugin AIDD installed for a tool whose registration can be asked about at all. - * Empty when the manifest records no plugin, a normal state rather than a fault. */ - readonly entries: readonly HostRegistrationEntry[]; - /** Why AIDD's own manifest could not be read, when it could not - its own field rather - * than an absent-entries silence, since `Manifest`'s parser reads `files.map(...)` - * unguarded and a truncated `.aidd/manifest.json` throws rather than returning null. A - * damaged manifest is exactly when a person runs `check`, so `check` must survive it. */ - readonly manifestUnreadable?: string; -} - -/** Whether a commit made by a session will carry the trailer that closes "this commit cost - * X", and whether any actually has. It is one line in `prepare-commit-msg`, erased whenever - * another tool regenerates that file - a loss with no symptom, since commits keep succeeding - * and records keep being written. */ -export interface TelemetryCommitTrailerSetup { - /** Where git says it runs hooks from - `git rev-parse --git-path hooks`, never `.git/hooks` - * assumed, since `core.hooksPath` elsewhere makes every other fact here describe the - * wrong directory. */ - readonly hooksDir?: string; - /** Which tool regenerates `prepare-commit-msg` and wipes anything appended to it, read - * from marker files at the repository root - never from the hook's own contents, which a - * regeneration has already overwritten. `undefined` means the CLI still owns the hook. */ - readonly hookManager?: HookManager; - /** Whether that manager's own config already calls the delegate. Present only when - * `hookManager` is. `false` is not a fault: it is the ordinary state of a repository - * nobody has wired up yet, which is what the printed job fixes. */ - readonly managerCallsDelegate?: boolean; - /** Why there is no `hooksDir`. Two causes, never one: a project outside git has no hook to - * carry anything, while a repository whose git could not answer is a reading that failed, - * and saying "no repository" about it would be false. */ - readonly hooksDirMissing?: "no-repository" | "unresolved"; - /** Whether the delegate script is there and executable. Present but not executable is its - * own state: git will not run it, and "installed" would be a lie nobody could act on. */ - readonly delegate: "executable" | "not-executable" | "absent"; - /** Whether `prepare-commit-msg` carries the line that calls the delegate. */ - readonly callSite: "present" | "missing" | "no-hook-file"; - /** Whether that hook is executable, when there is one. Git refuses to run a hook without - * the bit, so a regeneration that drops it leaves an install that looks perfect and writes - * nothing. Absent when there is no hook to ask about - a third state, not a `false`. */ - readonly hookExecutable?: boolean; - /** Whether that hook holds anything besides our own line. Said, never named: which tool it - * is changes nothing a person does, and naming one would be a guess from its contents. */ - readonly hookHasOtherContent: boolean; - /** How many of the commits looked at carry the trailer, and how many were looked at. A - * count, never a pass: "some of your recent commits carry it" is not something a person - * can check. Absent when history could not be read at all. */ - readonly recentlyCarrying?: { readonly carrying: number; readonly examined: number }; -} - -/** The two tools this build knows to regenerate `prepare-commit-msg` out from under - * whatever the CLI appended to it. */ -export type HookManager = "lefthook" | "husky"; - -/** Every spelling lefthook accepts for its own config file, in the order it looks for - * them. */ -export const LEFTHOOK_MARKER_NAMES = [ - "lefthook.yml", - "lefthook.yaml", - ".lefthook.yml", - ".lefthook.yaml", -] as const; - -/** The root marker that means husky owns this repository's hooks. */ -export const HUSKY_MARKER_NAME = ".husky"; - -/** The set a caller probes for existence before calling `detectHookManager`, in one place so - * the two never drift apart. */ -export const HOOK_MANAGER_MARKER_NAMES: readonly string[] = [ - ...LEFTHOOK_MARKER_NAMES, - HUSKY_MARKER_NAME, -]; - -/** Decided from root marker files alone, never from the hook's own contents: a manager - * regenerates the hook on every install, so the append this CLI made is already gone by the - * time anything reads it. Lefthook wins the tie when both are present. */ -export function detectHookManager(rootEntryNames: readonly string[]): HookManager | undefined { - if (rootEntryNames.some((name) => (LEFTHOOK_MARKER_NAMES as readonly string[]).includes(name))) { - return "lefthook"; - } - if (rootEntryNames.includes(HUSKY_MARKER_NAME)) return "husky"; - return undefined; -} diff --git a/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts b/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts deleted file mode 100644 index 635eea2b0..000000000 --- a/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { UnknownTelemetrySinkSchemaVersionError } from "../../../kernel/errors.js"; -import type { AiToolId } from "../../../kernel/tool.js"; -import type { StepAttributionSource } from "./step-attribution.js"; - -// v2 adds `provenance`, required rather than defaulted: a default meaning "the old route" -// is exactly the ambiguity the field exists to remove. -export const SINK_SCHEMA_VERSION = 2; - -/** `turn_id` is the key a re-read deduplicates on - the tool's own identifier, never a hash - * of the line, which changes the moment the tool appends to the record. No field says a - * record was provisional when stored: nothing outliving the moment of a read could confirm - * it closed, so what is stored is every reading strictly larger than the last. */ -export type TelemetrySinkRecordKind = "request" | "session"; - -/** Which route produced this line. Never optional: a default meaning "the old route" would - * make the field unreadable the day a third route appears. `"export"` can no longer be - * *produced* by this system, but stays in the union and every reader keeps honouring it, - * because a stored line outlives the code that wrote it. */ -export type TelemetrySinkRecordProvenance = "export" | "local-read"; - -/** The tool-neutral stored line, and the complete allowlist of what a session may leave - * behind: no identity of any kind on a *stored* export-provenance record, a person being - * named only via `person_id`, opted into on the local-read route. `vendor_field` and - * `turn_field` name the export-side attribute a value came from, which differs per tool. */ -export interface TelemetrySinkRecord { - readonly sink_schema_version: number; - readonly kind: TelemetrySinkRecordKind; - readonly provenance: TelemetrySinkRecordProvenance; - readonly tool: AiToolId; - readonly vendor_id: string; - readonly vendor_field: string; - readonly turn_id?: string; - readonly turn_field?: string; - /** The tool's own identifier for one billed call, not one turn - present only where a - * route can name it and, unlike `turn_id`, unique per billed request wherever present. It - * exists so a report can collapse two records describing one call into one, and is never - * used for the local-read re-read match `turn_id` exists for. */ - readonly billed_request_id?: string; - /** The prompt this billed call belongs to. A billed call and the prompt that caused it - * never share a transcript line, so the reader follows `parentUuid` back to one; the run - * journal writes the same identifier on `step_start`, so matching the two joins a step to - * a record exactly rather than inferring it from overlapping intervals. */ - readonly prompt_id?: string; - /** The skill a `Skill` call invoked inside this record's own prompt, stored because the - * report never re-reads a transcript. Scoped to the transcript the record sits in: a - * subagent that invoked its own skill did that work under it, so merging a prompt's - * several files first would pick one name for both. */ - readonly prompt_skill?: string; - /** How `step` came to be known. Never optional, for the same reason `provenance` is not: - * an absent field would read as "no step ran", the one assertion nothing on a transcript - * or a journal can support. */ - readonly step_attribution: StepAttributionSource; - /** Present only where `step_attribution` names a source that found one; absent, never a - * placeholder, when `step_attribution` is `"unattributed"`. */ - readonly step?: string; - /** The plugin a tool-stated `step` came bundled with, when the tool reports one - * alongside the skill name. Never set from a journal interval, which carries no plugin - * at all. */ - readonly step_plugin?: string; - readonly project_id?: string; - /** Which field on the run journal's `session_start` line `project_id` came from, present - * only on a record joined from a journal. Absent on an export-provenance record, whose - * `project_id` is set directly from an OTLP attribute with no join to name a source for. */ - readonly project_field?: string; - /** The identifier a person chose to attach to records this machine reads locally - never - * derived from `user_id`, a tool's own attribute, and never written onto an - * export-provenance record. Absent whenever nobody opted in, which is the default. */ - readonly person_id?: string; - /** A separate, later choice from `person_id` - present only once asked for, and never - * derived from it or from anything else. */ - readonly person_display_name?: string; - /** The CLI's own version, stamped only on what the CLI itself stored - a `provenance: - * "local-read"` record, never an `"export"` one. Never the framework's version and never - * the plugin's, which stamps the journal line beside this record instead. Absent on a - * record written before this field existed, which reads as an unknown version. */ - readonly cli_version?: string; - readonly cost_usd?: number; - readonly input_tokens?: number; - readonly output_tokens?: number; - readonly cache_read_tokens?: number; - readonly cache_creation_tokens?: number; - readonly model?: string; - readonly effort?: string; - readonly speed?: string; - readonly query_source?: string; - readonly agent_name?: string; - readonly duration_ms?: number; - readonly active_time_s?: number; - readonly event_timestamp?: string; - readonly event_sequence?: number; -} - -const DAY_KEY_LENGTH = "YYYY-MM-DD".length; - -/** The UTC day a record's own moment falls on, or `undefined` when it carries none. Lives - * here rather than in the sink adapter because the adapter and every double standing in for - * it must agree, and two implementations of "which day is this" diverge on exactly the - * inputs nobody writes a fixture for. */ -export function telemetrySinkRecordDayKey(record: TelemetrySinkRecord): string | undefined { - const at = record.event_timestamp; - // `typeof`, not `!== undefined`: `parseTelemetrySinkLine` checks the schema version and - // casts the rest, so a number here would parse as epoch milliseconds, land outside every - // real period and go missing from the read without being counted as undated. - if (typeof at !== "string") return undefined; - const parsed = new Date(at); - if (Number.isNaN(parsed.getTime())) return undefined; - return parsed.toISOString().slice(0, DAY_KEY_LENGTH); -} - -export function serializeTelemetrySinkRecord(record: TelemetrySinkRecord): string { - return JSON.stringify(record); -} - -export function parseTelemetrySinkLine(line: string): TelemetrySinkRecord { - const parsed = JSON.parse(line) as { sink_schema_version?: unknown }; - if (parsed.sink_schema_version !== SINK_SCHEMA_VERSION) { - throw new UnknownTelemetrySinkSchemaVersionError(parsed.sink_schema_version); - } - return parsed as TelemetrySinkRecord; -} diff --git a/cli/src/contexts/telemetry/domain/telemetry-sink-retention.ts b/cli/src/contexts/telemetry/domain/telemetry-sink-retention.ts deleted file mode 100644 index 5b311ade8..000000000 --- a/cli/src/contexts/telemetry/domain/telemetry-sink-retention.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** At ~576 bytes per mapped `request` line, about 25 MB over the window on a busy machine. */ -export const DEFAULT_TELEMETRY_SINK_RETENTION_DAYS = 90; - -export interface TelemetrySinkRetentionDecision { - readonly keep: readonly string[]; - readonly prune: readonly string[]; -} - -/** `windowDays` is clamped to at least 1: the newest day file is never a prune candidate. */ -export function decideTelemetrySinkRetention( - dayFileNames: readonly string[], - windowDays: number -): TelemetrySinkRetentionDecision { - const window = Math.max(1, Math.floor(windowDays)); - const sorted = [...dayFileNames].sort(); - if (sorted.length <= window) return { keep: sorted, prune: [] }; - return { - keep: sorted.slice(sorted.length - window), - prune: sorted.slice(0, sorted.length - window), - }; -} diff --git a/cli/src/contexts/telemetry/domain/telemetry-switch.ts b/cli/src/contexts/telemetry/domain/telemetry-switch.ts deleted file mode 100644 index 8c3d67d90..000000000 --- a/cli/src/contexts/telemetry/domain/telemetry-switch.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { join } from "node:path"; -import { AIDD_CONFIG_FILENAME, AIDD_DIR } from "../../../kernel/paths.js"; - -/** `.aidd/config.json`'s `telemetry` key, the one answer to "is AIDD allowed to measure this - * project", read fresh at every call. Absent or unparseable means off, the same failure - * direction the journal hook's own read takes. */ -export interface TelemetrySwitch { - readonly enabled: boolean; - /** A destination a since-removed `telemetry endpoint` command wrote here. Nothing reads it - * as a destination any more; `on` and `off` preserve it verbatim so neither drops a key it - * never wrote. A live export a tool still reads is `telemetry-export-leftover.ts`'s fact. */ - readonly endpoint?: string; -} - -export function telemetryConfigPath(projectRoot: string): string { - return join(projectRoot, AIDD_DIR, AIDD_CONFIG_FILENAME); -} - -/** The refusal at a person's own scope: an environment variable, refusable per shell and per - * machine, rather than a second file holding the same fact. Mirrors `repo.cjs`'s own predicate - * so hook and CLI cannot disagree — only the literal `"0"` refuses, and unset is not a choice - * this variable can express, so it never turns measurement on by itself. */ -export const TELEMETRY_REFUSAL_VARIABLE = "AIDD_TELEMETRY"; - -export function personRefusesTelemetry(env: NodeJS.ProcessEnv): boolean { - return env[TELEMETRY_REFUSAL_VARIABLE] === "0"; -} - -/** The person's refusal wins unconditionally; the project's tracked switch is read only when - * it does not apply — the same order and verdict `repo.cjs` computes. */ -export function resolveTelemetryEnabled( - fileSwitch: TelemetrySwitch | null, - env: NodeJS.ProcessEnv -): boolean { - if (personRefusesTelemetry(env)) return false; - return fileSwitch?.enabled === true; -} - -function asRecord(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function safeParse(content: string): unknown { - try { - return JSON.parse(content); - } catch { - return null; - } -} - -/** Unparseable content, or a `telemetry` key of the wrong shape, reads as `null` (off). */ -export function parseTelemetrySwitchFile(content: string): TelemetrySwitch | null { - const telemetry = asRecord(asRecord(safeParse(content))?.telemetry); - if (telemetry === null) return null; - const endpoint = typeof telemetry.endpoint === "string" ? telemetry.endpoint : undefined; - return { enabled: telemetry.enabled === true, endpoint }; -} - -/** Upserts `telemetry`, leaving every other top-level key untouched: a key this function did - * not add must survive both `on` and `off`. */ -export function buildTelemetrySwitchFile( - existingRaw: string | null, - next: TelemetrySwitch -): string { - const root = (existingRaw !== null ? asRecord(safeParse(existingRaw)) : null) ?? {}; - root.telemetry = - next.endpoint !== undefined - ? { enabled: next.enabled, endpoint: next.endpoint } - : { enabled: next.enabled }; - return `${JSON.stringify(root, null, 2)}\n`; -} diff --git a/cli/src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts deleted file mode 100644 index 4420545fc..000000000 --- a/cli/src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { mapCopilotEventsToSinkRecords } from "../domain/formats/copilot-events.js"; -import type { - LocalCostReadResult, - SessionCostReader, -} from "../domain/ports/session-cost-reader.js"; - -/** The session id names the exact file, so there is no directory to walk and the stamped - * `vendor_id` is the id asked for rather than one re-derived from the file's content. A - * missing file is no trace of the session, not a session that cost nothing. */ -export class CopilotCostReaderAdapter implements SessionCostReader { - constructor(private readonly homeDir: string) {} - - async read(sessionId: string): Promise { - const path = join(this.homeDir, ".copilot", "session-state", sessionId, "events.jsonl"); - let content: string; - try { - content = await readFile(path, "utf8"); - } catch { - return { records: [], sessionFound: false }; - } - return { records: mapCopilotEventsToSinkRecords(content, sessionId), sessionFound: true }; - } -} diff --git a/cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts deleted file mode 100644 index 42d5caef2..000000000 --- a/cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describeError } from "../../../kernel/describe-error.js"; -import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; -import type { HookTrustReader } from "../domain/ports/hook-trust-reader.js"; -import type { TelemetryCodexHookTrust } from "../domain/telemetry-claim.js"; - -// The exact table header Codex writes to `~/.codex/config.toml` once a hook is approved. The -// plugin's own `hook-trust.cjs` is gone, so these live here alone: SessionStart is the one -// event whose trust state explains an empty journal, and the literal is exported so -// `telemetry-evidence-adapter.ts` checks it rather than a copy that could drift. -export const PLUGIN_NAME = "aidd-telemetry"; -const HOOKS_FILE = "hooks/hooks.json"; -const SESSION_START_EVENT = "session_start"; - -// The recorder's own hook entry point. Exported so a hooks block declared in a project's -// own settings is recognised by the script it invokes, not by a loose substring. -export const HOOK_ENTRY_SCRIPT = "journal.cjs"; - -function codexConfigPath(homeDir: string): string { - return join(homeDir, ".codex", "config.toml"); -} - -// Line-scanned, not TOML-parsed: the one shape needed is a header Codex emits verbatim, -// directly followed by its `trusted_hash` line. Matched on the full key including the event -// name, so a hook approved under a renamed event reads as untrusted rather than approved. -function parseHookTrust(content: string): { trusted: boolean } { - const lines = content.split("\n"); - const prefix = `[hooks.state."${PLUGIN_NAME}@`; - const suffix = `:${HOOKS_FILE}:${SESSION_START_EVENT}:0:0"]`; - const at = lines.findIndex((line) => line.startsWith(prefix) && line.endsWith(suffix)); - if (at === -1) return { trusted: false }; - return { trusted: /^trusted_hash\s*=/.test((lines[at + 1] ?? "").trim()) }; -} - -export class HookTrustReaderAdapter implements HookTrustReader { - async read(): Promise { - const configPath = codexConfigPath(resolveHomeDir()); - let content: string; - try { - content = await readFile(configPath, "utf8"); - } catch (error) { - return { - readable: false, - reason: `${configPath} could not be read (${describeError(error)})`, - }; - } - return { readable: true, configPath, ...parseHookTrust(content) }; - } -} diff --git a/cli/src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts deleted file mode 100644 index 3918b8186..000000000 --- a/cli/src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { accessSync, constants } from "node:fs"; -import { delimiter, join } from "node:path"; -import { OpencodeExportError } from "../../../kernel/errors.js"; -import { mapOpencodeExportToSinkRecords } from "../domain/formats/opencode-export.js"; -import type { - LocalCostReadResult, - SessionCostReader, -} from "../domain/ports/session-cost-reader.js"; - -const BINARY = "opencode"; -// A local export of one session's own files — not a network call — so a generous budget -// still keeps a hung process from holding a read open. -const DEFAULT_TIMEOUT_MS = 10000; -// `opencode export` exits 1 for this exact condition too; only this message distinguishes -// "no such session" (nothing to read, not an error) from any other command failure. -const SESSION_NOT_FOUND = /session not found/i; - -/** Shells out to `opencode export --sanitize` rather than opening OpenCode's SQLite - * database: a native dependency would need a prebuild per platform and ABI, breaking - * `npm i -g` for every user to serve the fraction who use OpenCode. */ -export class OpencodeCostReaderAdapter implements SessionCostReader { - constructor(private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} - - async read(sessionId: string): Promise { - // No binary on the path is no trace of the session, not a session that cost nothing — - // the one case where this reader can say nothing at all about what OpenCode did. - if (!this.isAvailable()) return { records: [], sessionFound: false }; - const result = spawnSync(BINARY, ["export", sessionId, "--sanitize"], { - timeout: this.timeoutMs, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - if (result.error) { - throw new OpencodeExportError( - `${BINARY} export ${sessionId} failed: ${result.error.message}` - ); - } - if (result.status !== 0) return this.handleFailure(sessionId, result.status, result.stderr); - return { - records: mapOpencodeExportToSinkRecords( - this.parseExport(sessionId, result.stdout), - sessionId - ), - sessionFound: true, - }; - } - - /** A filesystem check, not a `--version` probe: spawning to test presence is flake-prone - * under load. */ - private isAvailable(): boolean { - const dirs = (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== ""); - return dirs.some((dir) => { - try { - accessSync(join(dir, BINARY), constants.X_OK); - return true; - } catch { - return false; - } - }); - } - - private handleFailure( - sessionId: string, - status: number | null, - stderr: string - ): LocalCostReadResult { - if (SESSION_NOT_FOUND.test(stderr)) return { records: [], sessionFound: false }; - throw new OpencodeExportError( - `${BINARY} export ${sessionId} exited with code ${status ?? "unknown"}: ${stderr.trim() || "no stderr output"}` - ); - } - - private parseExport(sessionId: string, stdout: string): unknown { - try { - return JSON.parse(stdout); - } catch (err) { - throw new OpencodeExportError( - `${BINARY} export ${sessionId} did not answer with JSON: ${err instanceof Error ? err.message : String(err)}` - ); - } - } -} diff --git a/cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts b/cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts deleted file mode 100644 index de5171434..000000000 --- a/cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { errorMessage } from "../../../kernel/describe-error.js"; -import { IdentityWriteError, UnreadableIdentityFileError } from "../../../kernel/errors.js"; -import { resolveAiddConfigDir } from "../../../kernel/reading/home-dir.js"; -import { asPlainObjectOrEmpty, isErrnoException } from "../../../kernel/reading/json-file.js"; -import { - withAlsoMeAdded, - withAlsoMeRemoved, - withPersonIdAdopted, -} from "../domain/person-resolution.js"; -import type { PersonIdentity } from "../domain/ports/person-identity-reader.js"; -import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; - -const PRIVATE_FILE_MODE = 0o600; -const PRIVATE_DIR_MODE = 0o700; - -// A file with no `origin` reads as `"minted"`: that is what the plugin's own deleted -// `identity.cjs` wrote, and `origin` is knowable only when an identity is created or adopted. -function parseIdentity(raw: string): PersonIdentity | null { - const parsed = asPlainObjectOrEmpty(JSON.parse(raw)); - if (typeof parsed.person_id !== "string" || parsed.person_id === "") return null; - const identity: { personId: string; origin: "minted" | "adopted"; alsoMe: string[] } = { - personId: parsed.person_id, - origin: parsed.origin === "adopted" ? "adopted" : "minted", - alsoMe: Array.isArray(parsed.also_me) - ? parsed.also_me.filter((v) => typeof v === "string") - : [], - }; - if (typeof parsed.display_name === "string" && parsed.display_name !== "") { - return { ...identity, displayName: parsed.display_name }; - } - return identity; -} - -// `also_me` is omitted when empty, like `display_name` when unset: the common case stays the -// quietest shape on disk. -function serializeIdentity(identity: PersonIdentity): string { - const record: { - person_id: string; - origin: "minted" | "adopted"; - display_name?: string; - also_me?: readonly string[]; - } = { person_id: identity.personId, origin: identity.origin }; - if (identity.displayName !== undefined) record.display_name = identity.displayName; - if (identity.alsoMe.length > 0) record.also_me = identity.alsoMe; - return `${JSON.stringify(record, null, 2)}\n`; -} - -/** Reads and writes only this machine's own user profile, never `AIDD_USER_CONFIG_DIR`: a - * team or CI can point that variable at a shared location, and an identity reachable that way - * would not be this person's own. `filePath` is resolved once, in the constructor, so a later - * relocation of `HOME` cannot change what this instance answers. */ -export class PersonIdentityAdapter implements PersonIdentityStore { - readonly filePath: string; - - constructor() { - this.filePath = join(resolveAiddConfigDir(), "identity.json"); - } - - async read(): Promise { - try { - return parseIdentity(await readFile(this.filePath, "utf8")); - } catch { - return null; - } - } - - async readStrict(): Promise { - const raw = await this.readFileOrNull(); - if (raw === null) return null; - try { - return parseIdentity(raw); - } catch (error) { - throw new UnreadableIdentityFileError(this.filePath, errorMessage(error)); - } - } - - async mint(): Promise { - const identity: PersonIdentity = { personId: randomUUID(), origin: "minted", alsoMe: [] }; - await this.write(identity); - return identity; - } - - async adopt(personId: string): Promise { - const identity = withPersonIdAdopted(await this.readStrict(), personId); - await this.write(identity); - return identity; - } - - async addAlsoMe(identity: string): Promise { - const next = withAlsoMeAdded(await this.requireCurrent("add"), identity); - await this.write(next); - return next; - } - - async removeAlsoMe(identity: string): Promise { - const next = withAlsoMeRemoved(await this.requireCurrent("remove"), identity); - await this.write(next); - return next; - } - - async setDisplayName(identity: PersonIdentity, displayName: string): Promise { - const next: PersonIdentity = { ...identity, displayName }; - await this.write(next); - return next; - } - - // `recursive: true` so withdrawing works even where the damaged identity file is a - // directory; `force: true` deliberately not set, since "already gone" is the one case this - // must report back rather than fold into success. `path` is the caller's, never re-derived. - async forget(path: string): Promise { - try { - await rm(path, { recursive: true }); - return true; - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return false; - throw new IdentityWriteError(path, error, "remove"); - } - } - - // The calling use case already refuses "nobody opted in", so this is the defensive - // fallback for that contract, not a path a normal call takes. - private async requireCurrent(action: "add" | "remove"): Promise { - const current = await this.readStrict(); - if (current !== null) return current; - throw new IdentityWriteError( - this.filePath, - new Error(`no identity exists to ${action} an identifier onto`), - "write" - ); - } - - private async readFileOrNull(): Promise { - try { - return await readFile(this.filePath, "utf8"); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return null; - throw new UnreadableIdentityFileError(this.filePath, errorMessage(error)); - } - } - - private async write(identity: PersonIdentity): Promise { - const filePath = this.filePath; - try { - await mkdir(dirname(filePath), { recursive: true, mode: PRIVATE_DIR_MODE }); - await writeFile(filePath, serializeIdentity(identity), { mode: PRIVATE_FILE_MODE }); - } catch (error) { - throw new IdentityWriteError(filePath, error); - } - } -} diff --git a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts deleted file mode 100644 index 9d7c55511..000000000 --- a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { readdir, readFile, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { resolvedRunsDir } from "../../../kernel/paths.js"; -import { isBareFileName } from "../../../kernel/reading/confined-file-name.js"; -import type { - RunJournal, - RunJournalBoundary, - RunJournalFileWritten, - RunJournalSessionStart, - RunJournalStore, - RunJournalTaskDeclared, -} from "../domain/ports/run-journal-reader.js"; - -/** The one schema this reader knows, mirroring `record.cjs`'s own `SCHEMA_VERSION` and - * pinned against it by the integration suite. A journal stating any other version is refused - * rather than read, since its lines can carry another shape entirely. */ -export const READABLE_JOURNAL_SCHEMA_VERSION = 2; - -const ULID_LENGTH = 26; // encodeTime(10) + encodeRandom(16), matching record.cjs's own ULID_LENGTH. -const RUN_FILE_EXTENSION = ".jsonl"; - -// Mirrors the hook's own `sanitizePathSegment` character for character, so a vendor id -// sanitized there on write matches what is sanitized here on read. Not a shared import: the -// hook is a zero-dependency CommonJS script. Exported so a test can pin the agreement. -export function sanitizePathSegment(segment: string): string { - const cleaned = segment.replace(/[^\w.-]/gu, "-"); - return cleaned === "" || cleaned === "." || cleaned === ".." ? "-" : cleaned; -} - -// Mirrors record.cjs's parseRunFileName: split on the fixed ULID length, never on "__", -// since a sanitized vendor id can itself contain that substring. -function matchesVendorId(entry: string, wantedSegment: string): boolean { - if (!entry.endsWith(RUN_FILE_EXTENSION)) return false; - const minLength = ULID_LENGTH + "__".length + RUN_FILE_EXTENSION.length; - if (entry.length <= minLength) return false; - if (entry.slice(ULID_LENGTH, ULID_LENGTH + 2) !== "__") return false; - return entry.slice(ULID_LENGTH + 2, -RUN_FILE_EXTENSION.length) === wantedSegment; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -/** Absence is never that statement, and neither is a non-finite value: a field nobody wrote, - * or a torn one, states nothing, and refusing it would drop attribution over an unknown. */ -function statesAnotherSchema(session: RunJournalSessionStart | undefined): boolean { - const stated = session?.schema_version; - return stated !== undefined && stated !== READABLE_JOURNAL_SCHEMA_VERSION; -} - -interface RawJournalLine { - readonly type?: unknown; - readonly at?: unknown; - readonly skill?: unknown; - readonly turn_id?: unknown; - readonly run_id?: unknown; - readonly tool?: unknown; - readonly vendor_id?: unknown; - readonly project_id?: unknown; - readonly project_remote?: unknown; - readonly worktree_id?: unknown; - readonly worktree_repo_id?: unknown; - readonly path?: unknown; - readonly plugin_version?: unknown; - readonly schema_version?: unknown; -} - -function parseLine(line: string): RawJournalLine | null { - const trimmed = line.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as RawJournalLine; - } catch { - return null; - } -} - -/** `null` for every other line type and every unparseable line: a torn final line from a - * session still in progress reads as nothing, not as a boundary at the wrong moment. */ -function parseBoundary(parsed: RawJournalLine): RunJournalBoundary | null { - const at = asString(parsed.at); - if (at === undefined) return null; - if (parsed.type === "turn_end") return { type: "turn_end", at }; - const skill = asString(parsed.skill); - if (skill === undefined) return null; - // An end with no skill is dropped rather than read as a bare boundary: it would close a - // step it cannot name. - if (parsed.type === "step_end") return { type: "step_end", at, skill }; - if (parsed.type !== "step_start") return null; - const turnId = asString(parsed.turn_id); - return { type: "step_start", at, skill, ...(turnId === undefined ? {} : { turn_id: turnId }) }; -} - -/** A plain checkout writes neither key, and an empty value is dropped with them, so a torn - * or empty value reads as "not stated" rather than as a worktree named nothing. */ -function parseWorktree( - parsed: RawJournalLine -): Pick { - const worktreeId = asString(parsed.worktree_id) || undefined; - const worktreeRepoId = asString(parsed.worktree_repo_id) || undefined; - return { - ...(worktreeId === undefined ? {} : { worktree_id: worktreeId }), - ...(worktreeRepoId === undefined ? {} : { worktree_repo_id: worktreeRepoId }), - }; -} - -/** `run_id`, `tool` and `vendor_id` are all required: a header naming two of the three - * cannot say which session it belongs to, and a half-read header is worse than none. */ -function parseSessionStart(parsed: RawJournalLine): RunJournalSessionStart | null { - if (parsed.type !== "session_start") return null; - const at = asString(parsed.at); - const runId = asString(parsed.run_id); - const tool = asString(parsed.tool); - const vendorId = asString(parsed.vendor_id); - if (at === undefined || runId === undefined || tool === undefined || vendorId === undefined) { - return null; - } - return { - type: "session_start", - at, - run_id: runId, - tool, - vendor_id: vendorId, - ...headerExtras(parsed), - }; -} - -/** Each field is absent rather than defaulted: a field the writer left out is one this - * reader has nothing to say about, and a default would be an answer nobody wrote. */ -function headerExtras(parsed: RawJournalLine): Partial { - const projectId = asString(parsed.project_id); - const projectRemote = asString(parsed.project_remote); - const pluginVersion = asString(parsed.plugin_version); - const schemaVersion = asNumber(parsed.schema_version); - return { - ...(schemaVersion === undefined ? {} : { schema_version: schemaVersion }), - ...(projectId === undefined ? {} : { project_id: projectId }), - ...(projectRemote === undefined ? {} : { project_remote: projectRemote }), - ...parseWorktree(parsed), - ...(pluginVersion === undefined ? {} : { plugin_version: pluginVersion }), - }; -} - -function parseFileWritten(parsed: RawJournalLine): RunJournalFileWritten | null { - if (parsed.type !== "file_written") return null; - const at = asString(parsed.at); - const writtenPath = asString(parsed.path); - return at === undefined || writtenPath === undefined - ? null - : { type: "file_written", at, path: writtenPath }; -} - -function parseTaskDeclared(parsed: RawJournalLine): RunJournalTaskDeclared | null { - if (parsed.type !== "task_declared") return null; - const at = asString(parsed.at); - const declaredPath = asString(parsed.path); - return at === undefined || declaredPath === undefined - ? null - : { type: "task_declared", at, path: declaredPath }; -} - -/** Mutable so `classifyLine` can fill it one line at a time without every caller threading - * four separate arrays through. */ -interface JournalCollector { - readonly boundaries: RunJournalBoundary[]; - readonly filesWritten: RunJournalFileWritten[]; - readonly taskDeclarations: RunJournalTaskDeclared[]; - session: RunJournalSessionStart | undefined; -} - -function newJournalCollector(): JournalCollector { - return { boundaries: [], filesWritten: [], taskDeclarations: [], session: undefined }; -} - -/** The four line types this port promises, tried in the order they are written most often. - * A line matching none of them is the header. */ -function classifyLine(collector: JournalCollector, parsed: RawJournalLine): void { - const boundary = parseBoundary(parsed); - if (boundary) { - collector.boundaries.push(boundary); - return; - } - const written = parseFileWritten(parsed); - if (written) { - collector.filesWritten.push(written); - return; - } - const declared = parseTaskDeclared(parsed); - if (declared) { - collector.taskDeclarations.push(declared); - return; - } - // The header is written once, first, so keeping the first one read means a second never - // silently replaces the identity the file opened with. - collector.session ??= parseSessionStart(parsed) ?? undefined; -} - -/** Never throws: a missing run file, an unreadable runs directory or a truncated final line - * all answer `null` or an empty list, since a damaged journal costs attribution, not the read - * itself. `AIDD_RUNS_DIR` overrides the directory, resolved once in the constructor so a - * later relocation cannot change what this instance answers. */ -export class RunJournalReaderAdapter implements RunJournalStore { - readonly runsDir: string; - - constructor(projectRoot: string) { - this.runsDir = resolvedRunsDir(projectRoot); - } - - async read(sessionId: string): Promise { - const filePath = await this.findRunFile(this.runsDir, sessionId); - return filePath ? this.readJournal(filePath) : null; - } - - async list(): Promise { - const dir = this.runsDir; - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return []; - } - const journals: RunJournal[] = []; - for (const entry of entries.sort()) { - if (!entry.endsWith(RUN_FILE_EXTENSION)) continue; - const journal = await this.readJournal(join(dir, entry)); - if (journal) journals.push(journal); - } - return journals; - } - - async listForeignSchemas(): Promise { - const stated: number[] = []; - for (const fileName of await this.listRunFiles()) { - const collector = await this.collect(join(this.runsDir, fileName)); - const version = collector?.session?.schema_version; - if (version !== undefined && version !== READABLE_JOURNAL_SCHEMA_VERSION) - stated.push(version); - } - return stated; - } - - async listRunFiles(): Promise { - try { - const entries = await readdir(this.runsDir); - return entries.filter((entry) => entry.endsWith(RUN_FILE_EXTENSION)).sort(); - } catch { - return []; - } - } - - // `force: true`: a name already gone is nothing to remove, never a failure. `isBareFileName` - // is the confinement — `join` normalises `..` away visually but still deletes wherever the - // result lands, so a name that is not a bare component of `dir` never reaches `rm`. - async deleteRunFile(dir: string, fileName: string): Promise { - if (!isBareFileName(fileName)) { - throw new Error(`refusing to delete "${fileName}" — not a run file name inside ${dir}`); - } - await rm(join(dir, fileName), { force: true }); - } - - private async findRunFile(dir: string, sessionId: string): Promise { - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return null; - } - const wanted = sanitizePathSegment(sessionId); - const match = entries.find((entry) => matchesVendorId(entry, wanted)); - return match ? join(dir, match) : null; - } - - private async collect(filePath: string): Promise { - let content: string; - try { - content = await readFile(filePath, "utf8"); - } catch { - return null; - } - const collector = newJournalCollector(); - for (const line of content.split("\n")) { - const parsed = parseLine(line); - if (parsed) classifyLine(collector, parsed); - } - return collector; - } - - private async readJournal(filePath: string): Promise { - const collector = await this.collect(filePath); - if (!collector || statesAnotherSchema(collector.session)) return null; - const { boundaries, filesWritten, taskDeclarations, session } = collector; - return { boundaries, filesWritten, taskDeclarations, ...(session ? { session } : {}) }; - } -} diff --git a/cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts b/cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts deleted file mode 100644 index 463050917..000000000 --- a/cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { asPlainObjectOrEmpty, isErrnoException } from "../../../kernel/reading/json-file.js"; -import { repositoryRootAbove } from "../../../kernel/reading/repository-root.js"; -import type { TaskBacklogReader } from "../domain/ports/task-backlog-reader.js"; -import type { TaskBacklogDeclaration, TaskBacklogLink } from "../domain/task-backlog-link.js"; - -/** The one file a task folder writes to declare its backlog item — see - * `domain/task-backlog-link.ts` for why this is not `metadata.json`. */ -export const TASK_BACKLOG_LINK_FILENAME = "backlog-link.json"; - -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value !== "" ? value : undefined; -} - -/** `null` for anything this file cannot be read as: a broken shape is evidence of a - * declaration someone attempted, so it must surface as damage rather than read the same as - * an absent file. */ -function parseLink(raw: string): TaskBacklogLink | null { - const parsed = asPlainObjectOrEmpty(JSON.parse(raw)); - const backlog = nonEmptyString(parsed.backlog); - const writtenAt = nonEmptyString(parsed.written_at); - const writtenBy = nonEmptyString(parsed.written_by); - if (backlog === undefined || writtenAt === undefined || writtenBy === undefined) return null; - return { backlog, writtenAt, writtenBy }; -} - -/** Never throws and never writes, on any path: that is what lets a report run against a - * checkout someone else owns without risking the work it describes. */ -export class TaskBacklogAdapter implements TaskBacklogReader { - private readonly repositoryRoot: string; - - // Resolved once, at construction, so a relocation afterwards cannot change what this - // instance answers. A task folder path arrives repository-relative, as the journal wrote it. - constructor(projectRoot: string) { - this.repositoryRoot = repositoryRootAbove(projectRoot); - } - - async read(taskFolderPath: string): Promise { - const filePath = join(this.repositoryRoot, taskFolderPath, TASK_BACKLOG_LINK_FILENAME); - let raw: string; - try { - raw = await readFile(filePath, "utf8"); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return { kind: "none" }; - return { kind: "unreadable" }; - } - try { - const link = parseLink(raw); - return link === null ? { kind: "unreadable" } : { kind: "declared", link }; - } catch { - return { kind: "unreadable" }; - } - } -} diff --git a/cli/src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts b/cli/src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts deleted file mode 100644 index e84578124..000000000 --- a/cli/src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { genericFlatHooksScriptPath } from "../../../kernel/materialization/flat-paths.js"; -import { AIDD_DIR, MANIFEST_FILENAME, resolvedRunsDir } from "../../../kernel/paths.js"; -import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; -import { isErrnoException } from "../../../kernel/reading/json-file.js"; -import { asPlainObject } from "../../../kernel/reading/plain-object.js"; -import { AI_TOOL_IDS } from "../../../kernel/tool.js"; -import { cursorProjectHooksScriptDir } from "../../tools/domain/formats/cursor-hooks-project-merge.js"; -import { hookCommandsForEvent } from "../../tools/domain/formats/flat-hooks-merge.js"; -import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../tools/domain/formats/plugin-root-token.js"; -import type { MarketplaceSettings } from "../../tools/domain/marketplace-settings.js"; -import { getAiToolConfig } from "../../tools/domain/registry.js"; -import type { - TelemetryEvidenceReader, - TelemetrySwitchSetupRead, - TelemetryUnrecognisedPayload, -} from "../domain/ports/telemetry-evidence-reader.js"; -import { - findLeftoverExportKeys, - type TelemetryExportLeftover, -} from "../domain/telemetry-export-leftover.js"; -import type { TelemetryRecorderDeclarationSetup } from "../domain/telemetry-setup.js"; -import { - parseTelemetrySwitchFile, - resolveTelemetryEnabled, - telemetryConfigPath, -} from "../domain/telemetry-switch.js"; -import { - HOOK_ENTRY_SCRIPT, - PLUGIN_NAME as RECORDER_PLUGIN_NAME, -} from "./hook-trust-reader-adapter.js"; - -const UNRECOGNISED_FILE_NAME = "_unrecognised.jsonl"; - -function manifestPath(projectRoot: string): string { - return join(projectRoot, AIDD_DIR, MANIFEST_FILENAME); -} - -// Claude Code is the only tool that ever wrote a settings-file export, so these three are -// where stale export keys can be. They double as the three real Claude hook scopes a person -// can hand-author into, but never as `enabledPlugins` locations: nothing writes that key to -// `settings.local.json` or the home settings file. -function claudeSettingsCandidates(projectRoot: string): readonly string[] { - return [ - join(projectRoot, ".claude", "settings.local.json"), - join(projectRoot, ".claude", "settings.json"), - join(resolveHomeDir(), ".claude", "settings.json"), - ]; -} - -// One location per AI tool declaring a `marketplaceSettings.enabledPluginsKey`, resolved the -// way `marketplace-sync-settings-use-case.ts` resolves it when writing, so this read can -// never disagree with the write it reads back. -function enabledPluginsCandidates(projectRoot: string): readonly string[] { - const paths: string[] = []; - for (const toolId of AI_TOOL_IDS) { - const caps = getAiToolConfig(toolId).capabilities as { - plugins?: { marketplaceSettings?: MarketplaceSettings | null }; - }; - const settings = caps.plugins?.marketplaceSettings; - if (!settings || settings.enabledPluginsKey === undefined) continue; - const path = join(projectRoot, settings.settingsPath); - if (!paths.includes(path)) paths.push(path); - } - return paths; -} - -// Cursor's plugin-scope hooks never fire, so this project-scope file in Cursor's flat -// `version: 1` shape is a Cursor install's only working declaration route. -function cursorHooksJsonPath(projectRoot: string): string { - return join(projectRoot, ".cursor", "hooks.json"); -} - -function dedupe(values: readonly string[]): readonly string[] { - return [...new Set(values)]; -} - -type DeclarationCheck = "declared" | "not-declared" | "unreadable"; - -type JsonRead = - | { readonly status: "absent" } - | { readonly status: "unreadable" } - | { readonly status: "ok"; readonly raw: string; readonly value: unknown }; - -// The seam where a present-but-damaged file (a trailing comma, unreadable permissions) is -// told apart from one that never existed. -async function readJsonIfExists(path: string): Promise { - let raw: string; - try { - raw = await readFile(path, "utf8"); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return { status: "absent" }; - return { status: "unreadable" }; - } - try { - return { status: "ok", raw, value: JSON.parse(raw) }; - } catch { - return { status: "unreadable" }; - } -} - -async function readIfExists(path: string): Promise { - try { - return await readFile(path, "utf8"); - } catch { - return null; - } -} - -/** An absent file is `readable: true` with nothing decided yet; any other read failure, or - * content that is not JSON, is `readable: false` — a damaged file, not a choice. A file that - * parses but names no `telemetry` key is readable with nothing ever set. */ -async function readSwitchFile(projectRoot: string): Promise<{ - readonly readable: boolean; - readonly fileSwitch: ReturnType; -}> { - let content: string; - try { - content = await readFile(telemetryConfigPath(projectRoot), "utf8"); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") { - return { readable: true, fileSwitch: null }; - } - return { readable: false, fileSwitch: null }; - } - try { - JSON.parse(content); - } catch { - return { readable: false, fileSwitch: null }; - } - return { readable: true, fileSwitch: parseTelemetrySwitchFile(content) }; -} - -/** A lenient walk of the raw JSON rather than `Manifest.fromJSON`'s strict schema, which - * throws on a shape this read must never crash over. */ -async function manifestDeclaresPlugin(path: string, pluginName: string): Promise { - const result = await readJsonIfExists(path); - if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; - const tools = asPlainObject(asPlainObject(result.value)?.tools); - if (tools === null) return "not-declared"; - const found = Object.values(tools).some((entry) => { - const plugins = asPlainObject(entry)?.plugins; - return Array.isArray(plugins) && plugins.some((p) => asPlainObject(p)?.name === pluginName); - }); - return found ? "declared" : "not-declared"; -} - -/** `enabledPlugins` keys look like `"@"`, so this is a prefix match: - * the marketplace half is this project's own choice, not the recorder's identity. */ -async function settingsDeclaresPlugin(path: string, pluginName: string): Promise { - const result = await readJsonIfExists(path); - if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; - const enabledPlugins = asPlainObject(asPlainObject(result.value)?.enabledPlugins); - if (enabledPlugins === null) return "not-declared"; - const prefix = `${pluginName}@`; - return Object.keys(enabledPlugins).some((key) => key.startsWith(prefix)) - ? "declared" - : "not-declared"; -} - -// Plugin-unique paths, never the bare leaf another plugin's hooks block could name just as -// easily. Each is multi-segment and forward-slashed on every platform, so a plain substring -// check matches a quoted command with no separate boundary logic. -const CLAUDE_HOOKS_TOKEN_MARKER = `${CLAUDE_PLUGIN_ROOT_TOKEN}/hooks/${HOOK_ENTRY_SCRIPT}`; -const CLAUDE_HOOKS_FLAT_MARKER = genericFlatHooksScriptPath( - ".claude/hooks/", - RECORDER_PLUGIN_NAME, - HOOK_ENTRY_SCRIPT -); -const CURSOR_HOOKS_MARKER = `${cursorProjectHooksScriptDir(RECORDER_PLUGIN_NAME)}${HOOK_ENTRY_SCRIPT}`; - -function invokesRecorderEntryPoint(command: string): boolean { - return ( - command.includes(CLAUDE_HOOKS_TOKEN_MARKER) || - command.includes(CLAUDE_HOOKS_FLAT_MARKER) || - command.includes(CURSOR_HOOKS_MARKER) - ); -} - -/** A hooks block is a declaration exactly like `enabledPlugins`, never proof: this only - * reads that the entry point was asked for. */ -async function hooksDeclarePlugin(path: string): Promise { - const result = await readJsonIfExists(path); - if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; - const commands = hookCommandsForEvent(result.raw, "SessionStart"); - return commands.some((command) => invokesRecorderEntryPoint(command)) - ? "declared" - : "not-declared"; -} - -function parseUnrecognisedPayload(raw: string): TelemetryUnrecognisedPayload | null { - const line = raw.split("\n").find((candidate) => candidate.trim() !== ""); - if (line === undefined) return null; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - return null; - } - const record = asPlainObject(parsed); - const at = record?.at; - if (record?.type !== "unrecognised_payload" || typeof at !== "string") return null; - return { at }; -} - -/** Evidence `aidd telemetry check` needs beyond the run journal, each tool's own local - * reader, and Codex's hook trust. */ -export class TelemetryEvidenceAdapter implements TelemetryEvidenceReader { - async isTelemetryEnabled(projectRoot: string, env: NodeJS.ProcessEnv): Promise { - const { fileSwitch } = await readSwitchFile(projectRoot); - return resolveTelemetryEnabled(fileSwitch, env); - } - - async readSwitchSetup(projectRoot: string): Promise { - const { readable, fileSwitch } = await readSwitchFile(projectRoot); - return { - path: telemetryConfigPath(projectRoot), - enabled: readable && fileSwitch?.enabled === true, - readable, - }; - } - - async readRecorderDeclaration(projectRoot: string): Promise { - const manifestFile = manifestPath(projectRoot); - const enabledPluginsFiles = enabledPluginsCandidates(projectRoot); - // A hooks block is a second, independent declaration route from `enabledPlugins`: every - // real Claude hook scope, plus Cursor's project-scope file. - const hooksFiles = [...claudeSettingsCandidates(projectRoot), cursorHooksJsonPath(projectRoot)]; - const locationsChecked = dedupe([manifestFile, ...enabledPluginsFiles, ...hooksFiles]); - const declaredAt: string[] = []; - const unreadable: string[] = []; - - const record = (path: string, outcome: DeclarationCheck): void => { - if (outcome === "declared") declaredAt.push(path); - else if (outcome === "unreadable") unreadable.push(path); - }; - - record(manifestFile, await manifestDeclaresPlugin(manifestFile, RECORDER_PLUGIN_NAME)); - for (const path of enabledPluginsFiles) { - record(path, await settingsDeclaresPlugin(path, RECORDER_PLUGIN_NAME)); - } - for (const path of hooksFiles) { - if (declaredAt.includes(path)) continue; - record(path, await hooksDeclarePlugin(path)); - } - return { - declared: declaredAt.length > 0, - declaredAt: dedupe(declaredAt), - locationsChecked, - unreadable: dedupe(unreadable), - }; - } - - async readUnrecognisedPayload(projectRoot: string): Promise { - try { - const content = await readFile( - join(resolvedRunsDir(projectRoot), UNRECOGNISED_FILE_NAME), - "utf8" - ); - return parseUnrecognisedPayload(content); - } catch { - return null; - } - } - - async findLeftoverExportConfig(projectRoot: string): Promise { - const leftovers: TelemetryExportLeftover[] = []; - for (const path of claudeSettingsCandidates(projectRoot)) { - const keys = findLeftoverExportKeys(await readIfExists(path)); - if (keys.length > 0) leftovers.push({ path, keys }); - } - return leftovers; - } -} diff --git a/cli/src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts b/cli/src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts deleted file mode 100644 index 56cb235ba..000000000 --- a/cli/src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { chmodSync, readdirSync } from "node:fs"; -import { access, appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { userInfo } from "node:os"; -import { join } from "node:path"; -import { TelemetrySinkUnwritableError } from "../../../kernel/errors.js"; -import { isBareFileName } from "../../../kernel/reading/confined-file-name.js"; -import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; -import type { - TelemetrySink, - TelemetrySinkAppendResult, - TelemetrySinkPeriodRead, -} from "../domain/ports/telemetry-sink.js"; -import { - parseTelemetrySinkLine, - serializeTelemetrySinkRecord, - type TelemetrySinkRecord, - telemetrySinkRecordDayKey, -} from "../domain/telemetry-sink-record.js"; - -const DAY_FILE_EXTENSION = ".jsonl"; -const PRIVATE_FILE_MODE = 0o600; -const PRIVATE_DIR_MODE = 0o700; - -const DAY_KEY_LENGTH = "YYYY-MM-DD".length; - -function dayKey(at: Date): string { - return at.toISOString().slice(0, DAY_KEY_LENGTH); -} - -function dayFileName(at: Date): string { - return `${dayKey(at)}${DAY_FILE_EXTENSION}`; -} - -async function pathExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function legacyConfigDir(): string { - return join(resolveHomeDir(), ".config", "aidd"); -} - -function hasLegacyTelemetryData(): boolean { - try { - const entries = readdirSync(join(legacyConfigDir(), "telemetry")); - return entries.some((entry) => entry.endsWith(DAY_FILE_EXTENSION)); - } catch { - return false; - } -} - -// `%APPDATA%`, not `.config`, is where a Windows application puts this; a machine that -// already journalled under `.config` keeps landing there rather than losing access to what it -// wrote. Exported so a test can pin the resolution on any platform, not only a Windows runner. -export function defaultConfigDir(): string { - if (process.platform !== "win32") return legacyConfigDir(); - if (hasLegacyTelemetryData()) return legacyConfigDir(); - return process.env.APPDATA ? join(process.env.APPDATA, "aidd") : legacyConfigDir(); -} - -// `mkdir`/`appendFile`'s `mode` is accepted on Windows without error and does nothing there; -// `icacls` is the mechanism that actually restricts a path. -function restrictToCurrentUser(target: string, options: { recursive?: boolean } = {}): void { - try { - const owner = process.env.USERDOMAIN - ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` - : (process.env.USERNAME ?? userInfo().username); - if (!owner) return; - const grant = options.recursive ? `${owner}:(OI)(CI)F` : `${owner}:F`; - const args = [target, "/inheritance:r", "/grant:r", grant]; - if (options.recursive) args.push("/T"); - args.push("/C", "/Q"); - spawnSync("icacls", args, { encoding: "utf8" }); - } catch { - // icacls missing, no resolvable owner, or a domain-policy refusal: leave it as it is. - } -} - -/** Every write is `appendFile`. `readRecordsForVendor` is the only method that reads a day - * file's content, and only so a local re-read knows what is already stored. */ -export class TelemetrySinkAdapter implements TelemetrySink { - /** `AIDD_TELEMETRY_DIR` names this directory outright; `AIDD_USER_CONFIG_DIR` names the - * directory *above* it and also relocates `auth.json`, a GitHub token, so it can never be - * the variable a team shares. */ - readonly rootDir: string; - // A user who names their own location keeps responsibility for its permissions: sharing a - // directory is what this exists for, and locking it to one account would break that. - private readonly userNamed: boolean; - - /** Carried because one answer has a consequence a person must be told about: - * `AIDD_USER_CONFIG_DIR` also names where `auth.json` is written, so anyone using it has a - * credential in the directory they were told to share. */ - readonly locatedBy: "telemetry-dir" | "user-config-dir" | "default"; - - constructor(userConfigDir?: string) { - const named = process.env.AIDD_TELEMETRY_DIR; - const legacy = userConfigDir ?? process.env.AIDD_USER_CONFIG_DIR; - this.userNamed = named !== undefined || legacy !== undefined; - this.rootDir = named ?? join(legacy ?? defaultConfigDir(), "telemetry"); - this.locatedBy = - named !== undefined ? "telemetry-dir" : legacy !== undefined ? "user-config-dir" : "default"; - } - - private tightenDir(): void { - if (this.userNamed) return; - if (process.platform === "win32") { - restrictToCurrentUser(this.rootDir, { recursive: true }); - return; - } - // `mkdir`'s own `mode` is masked by the process umask and applies only when it creates - // the directory, so it cannot make an existing one private. The listing is what needs - // protecting here: which days this person worked, and how many. - try { - chmodSync(this.rootDir, PRIVATE_DIR_MODE); - } catch { - // Someone else's directory, or a filesystem with no modes: the content stays 0600. - } - } - - // `/T` on the directory does not reliably carry the grant onto a leaf file it walks into, - // so a day file gets its own pass on the write that creates it. - private tightenFile(filePath: string): void { - if (this.userNamed || process.platform !== "win32") return; - restrictToCurrentUser(filePath, { recursive: false }); - } - - async ensureWritable(): Promise { - try { - await mkdir(this.rootDir, { recursive: true }); - this.tightenDir(); - const probePath = join(this.rootDir, `.write-check-${process.pid}`); - await writeFile(probePath, "", { mode: PRIVATE_FILE_MODE }); - await rm(probePath, { force: true }); - } catch (error) { - throw new TelemetrySinkUnwritableError(this.rootDir, error); - } - } - - async appendRecord(record: TelemetrySinkRecord, at: Date): Promise { - const filePath = join(this.rootDir, dayFileName(at)); - const dayFileIsNew = !(await pathExists(filePath)); - await mkdir(this.rootDir, { recursive: true }); - this.tightenDir(); - await appendFile(filePath, `${serializeTelemetrySinkRecord(record)}\n`, { - mode: PRIVATE_FILE_MODE, - }); - if (dayFileIsNew) this.tightenFile(filePath); - return { filePath, dayFileIsNew }; - } - - async listDayFiles(): Promise { - try { - const entries = await readdir(this.rootDir); - return entries.filter((entry) => entry.endsWith(DAY_FILE_EXTENSION)).sort(); - } catch { - return []; - } - } - - // `isBareFileName` is the confinement: `join` normalises `..` away visually but still - // deletes wherever the result lands, so a name that is not a bare component of the - // caller-supplied `dir` never reaches `rm`. - async deleteDayFile(dir: string, fileName: string): Promise { - if (!isBareFileName(fileName)) { - throw new Error(`refusing to delete "${fileName}" — not a day file name inside ${dir}`); - } - await rm(join(dir, fileName), { force: true }); - } - - async readRecordsForVendor(vendorId: string): Promise { - const records: TelemetrySinkRecord[] = []; - for (const fileName of await this.listDayFiles()) { - records.push(...(await this.readVendorRecordsFromFile(fileName, vendorId))); - } - return records; - } - - // Every day file is opened, not only the ones the period names: a session read days after - // it ran lands in today's file while its records carry their own, older moments, so - // selecting by file name would select by when we heard about the work. - async readRecordsInPeriod(fromDay: Date, toDay: Date): Promise { - const [fromKey, toKey] = [dayKey(fromDay), dayKey(toDay)].sort(); - const records: TelemetrySinkRecord[] = []; - const undated: TelemetrySinkRecord[] = []; - let skippedLines = 0; - const projects = new Set(); - const steps = new Set(); - const models = new Set(); - for (const fileName of await this.listDayFiles()) { - const read = await this.readAllRecordsFromFile(fileName); - skippedLines += read.skippedLines; - for (const record of read.records) { - if (record.project_id !== undefined) projects.add(record.project_id); - if (record.step !== undefined) steps.add(record.step); - if (record.model !== undefined) models.add(record.model); - const key = telemetrySinkRecordDayKey(record); - if (key === undefined) undated.push(record); - else if (key >= fromKey && key <= toKey) records.push(record); - } - } - return { records, undated, skippedLines, knownValues: { projects, steps, models } }; - } - - private async readAllRecordsFromFile( - fileName: string - ): Promise<{ records: TelemetrySinkRecord[]; skippedLines: number }> { - let content: string; - try { - content = await readFile(join(this.rootDir, fileName), "utf8"); - } catch { - // A file listed a moment ago and unreadable now — rotated, deleted, or never ours. - // Nothing about it is known, so nothing is counted as skipped either. - return { records: [], skippedLines: 0 }; - } - const records: TelemetrySinkRecord[] = []; - let skippedLines = 0; - for (const line of content.split("\n")) { - if (line.trim() === "") continue; - const record = this.parseLineOrSkip(line); - if (record) records.push(record); - else skippedLines += 1; - } - return { records, skippedLines }; - } - - private async readVendorRecordsFromFile( - fileName: string, - vendorId: string - ): Promise { - let content: string; - try { - content = await readFile(join(this.rootDir, fileName), "utf8"); - } catch { - // Same tolerance as `readAllRecordsFromFile`: a file listed a moment ago and - // unreadable now must not fail a vendor-scoped read any more than a full one. - return []; - } - const records: TelemetrySinkRecord[] = []; - for (const line of content.split("\n")) { - if (line.trim() === "") continue; - const record = this.parseLineOrSkip(line); - if (record?.vendor_id === vendorId) records.push(record); - } - return records; - } - - // A torn final line or a stray older-schema line must not fail an unrelated session's - // read: skipped, since no typed exception is useful for one line among many. - private parseLineOrSkip(line: string): TelemetrySinkRecord | undefined { - try { - return parseTelemetrySinkLine(line); - } catch { - return undefined; - } - } -} diff --git a/cli/src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.ts deleted file mode 100644 index 0394ac0cd..000000000 --- a/cli/src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Dirent } from "node:fs"; -import { createReadStream } from "node:fs"; -import { readdir } from "node:fs/promises"; -import { join, relative } from "node:path"; -import { createInterface } from "node:readline"; -import type { TranscriptLocation } from "../../../kernel/measurement.js"; -import type { - LocalCostCandidateRecord, - LocalCostReadResult, - SessionCostReader, - TranscriptLineAccumulator, -} from "../domain/ports/session-cost-reader.js"; - -async function* walk(dir: string): AsyncGenerator { - let entries: Dirent[]; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const absolutePath = join(dir, entry.name); - if (entry.isDirectory()) yield* walk(absolutePath); - else if (entry.isFile()) yield absolutePath; - } -} - -/** Where to search and which names belong to a session are the tool's own declaration; this - * class walks and reads, and encodes no path of its own. No matching file answers - * `sessionFound: false`, a different fact from a transcript holding nothing billable. Read - * through `readline`, so a large transcript is never held whole in memory. */ -export class TranscriptCostReaderAdapter implements SessionCostReader { - constructor( - private readonly homeDir: string, - private readonly location: TranscriptLocation, - private readonly createAccumulator: () => TranscriptLineAccumulator - ) {} - - async read(sessionId: string): Promise { - const root = this.location.root(this.homeDir); - const files = await this.findMatchingFiles(root, sessionId); - const records: LocalCostCandidateRecord[] = []; - for (const file of files) { - records.push(...(await this.readFile(file))); - } - return { records, sessionFound: files.length > 0 }; - } - - private async findMatchingFiles(root: string, sessionId: string): Promise { - const matches: string[] = []; - for await (const absolutePath of walk(root)) { - const relativePath = relative(root, absolutePath); - if (this.location.matches(relativePath, sessionId)) matches.push(absolutePath); - } - return matches; - } - - private async readFile(path: string): Promise { - const accumulator = this.createAccumulator(); - const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity }); - for await (const line of lines) accumulator.push(line); - return accumulator.build(); - } -} diff --git a/cli/src/contexts/tools/domain/contracts.ts b/cli/src/contexts/tools/domain/contracts.ts index 588aed6f2..13f22d300 100644 --- a/cli/src/contexts/tools/domain/contracts.ts +++ b/cli/src/contexts/tools/domain/contracts.ts @@ -1,4 +1,3 @@ -import type { TelemetryLocalRead } from "../../../kernel/measurement.js"; import type { AiToolId, IdeToolId } from "../../../kernel/tool.js"; import type { ToolBuildContract } from "./build-contract.js"; import type { AgentsCapability } from "./capabilities/agents-capability.js"; @@ -48,21 +47,6 @@ export interface AiTool { /** How the vendor writes it. `toolId` is a key, not a name: nothing user-facing * should print `copilot` where a person reads "GitHub Copilot". */ readonly displayName: string; - /** Whether this tool's own file(s) can be read locally for a session's counters — see - * {@link TelemetryLocalRead}. */ - readonly telemetryLocalRead: TelemetryLocalRead; - /** How the run journal's hook names this tool in its own `session_start` line — not the same - * string as `toolId`, since the hook detects a host from a payload's shape and spells Claude - * Code `claude-code`. Absent for a tool the journal hook does not run under. */ - readonly telemetryJournalHost?: string; - /** Whether a session on this tool can be traced to the task it worked on: true wherever - * `journal.cjs`'s `tool-used` dispatch reaches the host at all, since a task can be *declared* - * — a tool call's own arguments named a file under a task folder — asking nothing of the - * host's payload shape. `false` would mean no tool-used event ever reaches that host, which a - * declaration cannot work around. A tool declaring `false` is still reportable by period and - * by step; it simply belongs to no task. The truth lives in the framework's own hook scripts, - * which this side cannot import, so it is declared here and pinned by a test. */ - readonly telemetryTaskAttributable: boolean; readonly directory: string; readonly toolSuffix: string; readonly signalDir: string | null; diff --git a/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts b/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts index f5a06806f..b81cfc372 100644 --- a/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts +++ b/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts @@ -4,8 +4,6 @@ * events it supports to camelCase. */ -import { asPlainObject } from "../../../../kernel/reading/plain-object.js"; - type ClaudeHookItem = { type?: string; command?: string; [key: string]: unknown }; type ClaudeMatcherGroup = { matcher?: string; hooks: ClaudeHookItem[] }; type ClaudeHooksShape = { hooks?: Record }; @@ -222,41 +220,3 @@ function buildCodexHookItem(item: ClaudeHookItem): { if (typeof item.statusMessage === "string") entry.statusMessage = item.statusMessage; return entry; } - -/** - * Every `command` string registered for `claudeEvent` in a hooks file already written in any of - * the four shapes this module writes, plus whatever alias `CURSOR_EVENT_MAP` maps that event to. - * Malformed content, or a shape none of the four writers produce, answers `[]` rather than - * throwing: an unrecognised shape is not evidence of anything. A reader asking whether a hooks - * block called for a command calls this rather than restating the four shapes, so a fifth shape - * recognised here is recognised there too. - */ -export function hookCommandsForEvent(hooksFileContent: string, claudeEvent: string): string[] { - let parsed: unknown; - try { - parsed = JSON.parse(hooksFileContent); - } catch { - return []; - } - const hooks = asPlainObject(asPlainObject(parsed)?.hooks); - if (hooks === null) return []; - const commands: string[] = []; - for (const eventName of [claudeEvent, ...(CURSOR_EVENT_MAP[claudeEvent] ?? [])]) { - const entries = hooks[eventName]; - if (Array.isArray(entries)) for (const entry of entries) collectCommands(entry, commands); - } - return commands; -} - -// Both known entry depths in one walk: a nested group (`{ hooks: [...] }`, Claude/Codex) -// recurses one level into its own `hooks` array; a flat entry (`{ command }`, Copilot/Cursor) -// has none and contributes its own command directly. -function collectCommands(entry: unknown, out: string[]): void { - const record = asPlainObject(entry); - if (record === null) return; - if (Array.isArray(record.hooks)) { - for (const nested of record.hooks) collectCommands(nested, out); - return; - } - if (typeof record.command === "string") out.push(record.command); -} diff --git a/cli/src/contexts/tools/domain/host-plugin-registration.ts b/cli/src/contexts/tools/domain/host-plugin-registration.ts index fdf04ca6b..d74b2cfa2 100644 --- a/cli/src/contexts/tools/domain/host-plugin-registration.ts +++ b/cli/src/contexts/tools/domain/host-plugin-registration.ts @@ -6,7 +6,7 @@ import type { /** * Whether a host will actually load a plugin AIDD installed for it — the comparison - * `telemetry`'s own diagnostic and `framework`'s `doctor` both need and neither owns. A + * `framework`'s `doctor` needs. A * project's settings can carry a perfectly good `enabledPlugins` entry while the host's * registry knows nothing about it, at which point the host drops the entry as orphaned and * every visible signal still says healthy. diff --git a/cli/src/contexts/tools/domain/profiles/claude/claude-transcript-location.ts b/cli/src/contexts/tools/domain/profiles/claude/claude-transcript-location.ts deleted file mode 100644 index 9d81cf755..000000000 --- a/cli/src/contexts/tools/domain/profiles/claude/claude-transcript-location.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { sep } from "node:path"; -import type { TranscriptLocation } from "../../../../../kernel/measurement.js"; - -/** Where Claude Code keeps a session's transcript, and which file belongs to which session. - * Declared beside the profile that names it: only the tool knows its own directory layout, - * and the adapter that opens the files never encodes one itself. */ -function matchesMainTranscript(segments: readonly string[], sessionId: string): boolean { - return segments.length === 2 && segments[1] === `${sessionId}.jsonl`; -} - -function matchesSubagentTranscript(segments: readonly string[], sessionId: string): boolean { - return ( - segments.length === 4 && - segments[1] === sessionId && - segments[2] === "subagents" && - segments[3].endsWith(".jsonl") - ); -} - -export const CLAUDE_CODE_TRANSCRIPT_LOCATION: TranscriptLocation = { - root: (homeDir) => `${homeDir}${sep}.claude${sep}projects`, - matches: (relativePath, sessionId) => { - const segments = relativePath.split(sep); - return ( - matchesMainTranscript(segments, sessionId) || matchesSubagentTranscript(segments, sessionId) - ); - }, -}; diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts index 8752f893d..2eeeeb2c6 100644 --- a/cli/src/contexts/tools/domain/profiles/claude/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -20,7 +20,6 @@ import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; import { claudeStyleMarketplaceKey } from "../../marketplace-entry.js"; import { registerTool } from "../../registry.js"; import { buildClaudeContract, buildClaudeFlatContract } from "./build.js"; -import { CLAUDE_CODE_TRANSCRIPT_LOCATION } from "./claude-transcript-location.js"; const DIRECTORY = ".claude/"; const TOOL_SUFFIX = ".claude.md"; @@ -40,15 +39,6 @@ export const claude: AiTool `${homeDir}${sep}.codex${sep}sessions`, - matches: (relativePath, sessionId) => { - const base = relativePath.split(sep).pop() ?? relativePath; - return base.startsWith("rollout-") && base.endsWith(`-${sessionId}.jsonl`); - }, -}; diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts index adb01072b..84413d537 100644 --- a/cli/src/contexts/tools/domain/profiles/codex/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -30,7 +30,6 @@ import { mergeCodexConfigToml, stripCodexSkillFrontmatter, } from "./build.js"; -import { CODEX_ROLLOUT_LOCATION } from "./codex-transcript-location.js"; const DIRECTORY = ".codex/"; const TOOL_SUFFIX = ".codex.md"; @@ -134,15 +133,6 @@ export const codex: AiTool< directory: DIRECTORY, toolSuffix: TOOL_SUFFIX, displayName: "Codex", - telemetryLocalRead: { - kind: "declared", - transcript: CODEX_ROLLOUT_LOCATION, - // Complete counters per turn, no currency anywhere in a rollout, and no field naming a - // running skill - so a step here can only ever come from a run journal interval. - supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - }, - telemetryTaskAttributable: true, - telemetryJournalHost: "codex", signalDir: `${DIRECTORY}commands`, configOutputPaths: { "config.toml": ".codex/config.toml" }, buildContracts: { marketplace: buildCodexContract, flat: buildCodexFlatContract }, diff --git a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts index b94791a8a..46c17c0d5 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts @@ -213,21 +213,6 @@ export const copilot: AiTool< directory: DIRECTORY, toolSuffix: TOOL_SUFFIX, displayName: "GitHub Copilot", - telemetryLocalRead: { - kind: "declared", - supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - // `input` is measured exclusive of `cache_read`: on a session carrying a non-zero - // `cache_read`, 9 (`input`) + 42038 (`cache_read`) + 21404 (`cache_write`) = 63451, exactly - // `modelMetrics..usage.inputTokens`. The four counters this reader stores are - // therefore disjoint and the report is right to add them; an `input` that included - // `cache_read` would have over-counted every Copilot session by its cached share. - limitation: - "Its own file names outputTokens per turn, but session.shutdown carries all four " + - "counters for the whole session — a session total, never a sum of requests. Its four " + - "counters are measured disjoint, cached prompt included.", - }, - telemetryTaskAttributable: true, - telemetryJournalHost: "copilot", signalDir: ".github/prompts", buildContracts: { marketplace: buildCopilotMarketplaceContract, diff --git a/cli/src/contexts/tools/domain/profiles/cursor/profile.ts b/cli/src/contexts/tools/domain/profiles/cursor/profile.ts index cb2854479..c7556a36d 100644 --- a/cli/src/contexts/tools/domain/profiles/cursor/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/cursor/profile.ts @@ -43,12 +43,6 @@ export const cursor: AiTool every Stop hook's own {script, args, payload} - or \`[]\` for * any other event. Exported as a property (never a second named export - F6) so this - * generated module's own mapping can be asserted without spawning anything, the same seam - * \`AiddTelemetry.journalCallFor\` already gives opencode-plugin.js. */ + * generated module's own mapping can be asserted without spawning anything. */ function stopCallsFor(event, directory) { if (event?.type !== "session.idle") return []; const sessionId = event.properties?.sessionID; @@ -189,8 +185,7 @@ function postToolUseCallsFor(event, directory) { export const ${ident} = async (input) => { // SessionStart's own approximation (module doc comment above): fired once here, never - // per session. Silent on purpose, the same rule journal.cjs's own main() and - // opencode-plugin.js's own event handler both state: a measurement or a memory refresh + // per session. Silent on purpose: a memory refresh // that breaks OpenCode's own startup is worse than one that never ran. try { for (const hook of SESSION_START) { diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts index d12add210..a44e83716 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -48,24 +48,6 @@ export const opencode: AiTool< directory: DIRECTORY, toolSuffix: TOOL_SUFFIX, displayName: "OpenCode", - telemetryLocalRead: { - kind: "declared", - // Counters per message, and no amount: `info.cost` is `0` in every message captured and its - // denomination was never established, so it is deliberately never read. No field names a - // running skill either. - supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - // `input` is measured exclusive of `cache.read` for providerID "anthropic", matching that - // API's own documented behaviour. A second, OpenAI-compatible provider reconciled the same - // way but never exercised its cache across two turns, so it corroborates without confirming; - // a provider reporting prompt tokens inclusive of the cached ones has never been captured. - limitation: - "Its four counters are measured disjoint for the anthropic provider and for one " + - "OpenAI-compatible provider whose cache was exercised — not confirmed for a " + - "provider that reports prompt tokens inclusive of the cached ones, which none " + - "captured here does.", - }, - telemetryTaskAttributable: true, - telemetryJournalHost: "opencode", signalDir: ".opencode/commands", configOutputPaths: { "opencode.json": "opencode.json" }, pluginRuntimeFiles: { [OPENCODE_EVENTS_ASSET]: OPENCODE_EVENTS_PATH }, diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts index 75cfa0968..dbd2383d2 100644 --- a/cli/src/contexts/tools/domain/registry.ts +++ b/cli/src/contexts/tools/domain/registry.ts @@ -74,17 +74,6 @@ export function getAiToolConfig(toolId: AiToolId): AiTool { return config; } -/** The `AiToolId` whose declaration claims a journal host, or `null` for a host no registered - * tool claims. The one place the journal hook's host names and this codebase's tool ids are - * related, and it relates them by reading declarations rather than a table a fifth host would - * have to be remembered into. */ -export function journalHostToAiToolId(journalHost: string): AiToolId | null { - for (const toolId of AI_TOOL_IDS) { - if (getAiToolConfig(toolId).telemetryJournalHost === journalHost) return toolId; - } - return null; -} - export function getAllRegisteredTools(): Map { return new Map(TOOL_REGISTRY); } diff --git a/cli/src/kernel/describe-error.ts b/cli/src/kernel/describe-error.ts index d52fa34a7..98ebd4cdc 100644 --- a/cli/src/kernel/describe-error.ts +++ b/cli/src/kernel/describe-error.ts @@ -7,13 +7,3 @@ export function describeError(error: unknown): string { } return error instanceof Error ? error.message : String(error); } - -/** - * The message alone, for a failure whose `code` says nothing worth reading — a JSON parse - * error being the case that matters here, where the `SyntaxError`'s message is the whole - * answer and there is no `code` at all. Beside `describeError` because the two are one - * decision with two answers. - */ -export function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts index b3200cd8d..b38753311 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -667,104 +667,3 @@ export class ToolNotInstalledError extends Error { this.name = "ToolNotInstalledError"; } } - -export class UnknownTelemetrySinkSchemaVersionError extends Error { - constructor(version: unknown) { - super( - `Unknown telemetry sink schema version '${String(version)}' — refusing to guess its shape.` - ); - this.name = "UnknownTelemetrySinkSchemaVersionError"; - } -} - -/** A genuine `opencode export` failure — a non-zero exit not explained by "no such - * session", or a timeout. An absent binary or an unknown session mean the machine holds no - * OpenCode data, and resolve to an empty array instead of throwing. */ -export class OpencodeExportError extends Error { - constructor(message: string) { - super(message); - this.name = "OpencodeExportError"; - } -} - -export class InvalidReportDayError extends Error { - constructor(flag: string, value: string) { - super(`Invalid ${flag} '${value}'. Expected a UTC day, as YYYY-MM-DD.`); - this.name = "InvalidReportDayError"; - } -} - -export class InvalidReportSpanError extends Error { - constructor(value: string, maxDays: number) { - super(`Invalid --days '${value}'. Expected an integer between 1 and ${maxDays}.`); - this.name = "InvalidReportSpanError"; - } -} - -/** The identity file exists but could not be read back — a read failure (e.g. it is a - * directory) or content that does not parse. Distinct from no file at all, which is a - * person never having opted in and answers `null` rather than throwing. */ -export class UnreadableIdentityFileError extends Error { - constructor(filePath: string, cause: string) { - super(`Could not read the identity file at ${filePath} (${cause}).`); - this.name = "UnreadableIdentityFileError"; - } -} - -/** One consequence, shared by `endpoint --scope project` and `telemetry on`: writing a - * git-tracked file that turns telemetry on for everyone who clones. `action` and - * `trackedPath` are all that differ between them. */ -export class TelemetryProjectScopeRequiresYesError extends Error { - constructor(action: string, trackedPath: string) { - super( - `${action} writes the git-tracked ${trackedPath}, turning telemetry on for ` + - "everyone who clones. Pass --yes to confirm." - ); - this.name = "TelemetryProjectScopeRequiresYesError"; - } -} - -export class EmptyDisplayNameError extends Error { - constructor() { - super("`aidd telemetry identity use --name` needs a non-empty value."); - this.name = "EmptyDisplayNameError"; - } -} - -export class IdentityRequiredToLinkError extends Error { - constructor() { - super("No identity to link onto yet. Run `aidd telemetry identity use` first."); - this.name = "IdentityRequiredToLinkError"; - } -} - -export class EmptyIdentifierError extends Error { - constructor(command: "use" | "link") { - super(`\`aidd telemetry identity ${command}\` needs a non-empty value.`); - this.name = "EmptyIdentifierError"; - } -} -export class TelemetrySinkUnwritableError extends Error { - constructor(path: string, cause: unknown) { - super( - `Telemetry sink directory is not writable: ${path} ` + - `(${cause instanceof Error ? cause.message : String(cause)})` - ); - this.name = "TelemetrySinkUnwritableError"; - } -} - -/** A write or a delete against the identity file failed for a reason other than the file - * not being there — permission denied, a full disk. `UnreadableIdentityFileError` above is - * the read that could not come back; this is the write that could not go out. */ -export class IdentityWriteError extends Error { - /** `action` names what the person was doing, because the sentence reaches them: someone - * withdrawing should not be told a write failed. */ - constructor(filePath: string, cause: unknown, action: "write" | "remove" = "write") { - super( - `Could not ${action} the identity file at ${filePath} ` + - `(${cause instanceof Error ? cause.message : String(cause)}).` - ); - this.name = "IdentityWriteError"; - } -} diff --git a/cli/src/kernel/measurement.ts b/cli/src/kernel/measurement.ts deleted file mode 100644 index 0dc76b025..000000000 --- a/cli/src/kernel/measurement.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** What a route was **measured to supply**, not what it might: a consumer has to tell apart - * five states that all look like a missing number — no counters at all, counters without an - * amount, an amount, figures carrying the step the tool itself named, figures carrying the - * agent a record belongs to. - * - * Per route rather than per tool, since different tools' local reads carry different things. - * Every field is required: a default would assert a capability nobody measured. */ -export interface TelemetryRouteSupply { - /** The four token counters. */ - readonly tokenCounters: boolean; - /** A figure denominated in currency. Never a credit, a premium request, or a zero whose - * denomination was never established. */ - readonly amount: boolean; - /** The tool names the running step itself, on the record. An interval derived from the - * run journal is not this — that is the framework's inference, not the tool's statement. */ - readonly toolStatedStep: boolean; - /** The tool names the agent a record belongs to, and so also says when a record is the - * main thread's own. Without it a record carrying no agent states nothing, and `by_agent` - * may not read it as the main thread. */ - readonly agentName: boolean; -} - -/** Where a tool's own transcript files live and how to recognise the one for a session — - * declared per tool, so the adapter that opens files encodes no layout itself. `matches` - * receives the candidate's path relative to `root`, never its basename: Claude Code's - * subagent transcripts (`/subagents/*.jsonl`) differ only by that nesting. */ -export interface TranscriptLocation { - root(homeDir: string): string; - matches(relativePath: string, sessionId: string): boolean; -} - -/** This tool's own file(s) can be read for a session's counters with nothing exported and no - * process running — read through a use case that asks each declaration and never branches on - * `toolId`. `transcript` is optional: a tool read by another means entirely (OpenCode shells - * out to its own CLI) declares none. */ -export interface TelemetryLocalReadDeclared { - readonly kind: "declared"; - readonly transcript?: TranscriptLocation; - readonly supplies: TelemetryRouteSupply; - /** A caveat that survives to the person reading the result, when this tool can be read for - * less than the others. Data rather than a source comment: a comment reaches nobody - * downstream, leaving a consumer to guess why the figures are thin. */ - readonly limitation?: string; -} - -/** This tool's own file cannot yield what a local read needs, established by probe rather - * than assumed from an empty result. */ -export interface TelemetryLocalReadUnsupported { - readonly kind: "unsupported"; - readonly reason: string; -} - -export type TelemetryLocalRead = TelemetryLocalReadDeclared | TelemetryLocalReadUnsupported; diff --git a/cli/src/kernel/paths.ts b/cli/src/kernel/paths.ts index 4c1e3914b..43107d5ac 100644 --- a/cli/src/kernel/paths.ts +++ b/cli/src/kernel/paths.ts @@ -1,5 +1,4 @@ import { join, posix, win32 } from "node:path"; -import { repositoryRootAbove } from "./reading/repository-root.js"; export const AIDD_DIR = ".aidd"; export const AIDD_CONFIG_FILENAME = "config.json"; @@ -11,26 +10,10 @@ export const AIDD_MARKETPLACES_FILENAME = "marketplaces.json"; * `clean` purges it, and a second spelling is how one of them forgets the other. */ export const USER_SOURCE_REFERENCES_FILENAME = "references.json"; export const DOCS_DIR = "aidd_docs" as const; -export const RUNS_SUBDIR = "runs" as const; export const PLUGIN_CACHE_SUBDIR = join(AIDD_DIR, "plugin-cache"); export const MARKETPLACE_CACHE_SUBDIR = join(AIDD_DIR, "cache", "marketplaces"); export const BUILT_CACHE_SUBDIR = join(AIDD_DIR, "cache", "built"); -// The one spelling of "the run journal's directory, as a gitignore/pathspec entry": -// `telemetry-on-use-case.ts` and `forget-telemetry-use-case.ts` both ask -// `VersionControl.listTrackedFiles` about exactly this path. -export const RUNS_ENTRY = `${DOCS_DIR}/${RUNS_SUBDIR}/`; - -/** - * Where the run journal lives — at the repository root above `projectRoot`, never - * `projectRoot` itself, because the hook that writes it anchors there (`repositoryRootAbove` - * carries why). The one resolver, so two readers cannot disagree from a subdirectory. - * `AIDD_RUNS_DIR` overrides it outright, matching the hook. - */ -export function resolvedRunsDir(projectRoot: string): string { - return process.env.AIDD_RUNS_DIR || join(repositoryRootAbove(projectRoot), DOCS_DIR, RUNS_SUBDIR); -} - export function marketplaceCacheDir(projectRoot: string, marketplaceName: string): string { return join(projectRoot, MARKETPLACE_CACHE_SUBDIR, marketplaceName); } @@ -218,9 +201,7 @@ export function userManifestPath(userConfigDir: string): string { return join(userConfigDir, MANIFEST_FILENAME); } -/** The manifest's filename, named once because `telemetry-evidence-adapter.ts` and - * `manifest-repository-adapter.ts` both open that file and `aidd telemetry check` prints a - * row from each: two literals would let a rename make those rows contradict each other. */ +/** The manifest's filename, named once so every reader and writer of that file spells it the same. */ export const MANIFEST_FILENAME = "manifest.json"; /** diff --git a/cli/src/kernel/ports/file-writer.ts b/cli/src/kernel/ports/file-writer.ts index 1c0455a59..bede73f9d 100644 --- a/cli/src/kernel/ports/file-writer.ts +++ b/cli/src/kernel/ports/file-writer.ts @@ -4,5 +4,4 @@ export interface FileWriter { createDirectory(path: string): Promise; deleteEmptyDirectories(path: string): Promise; deleteDirectory(path: string): Promise; - chmodExecutable(path: string): Promise; } diff --git a/cli/src/kernel/reading/confined-file-name.ts b/cli/src/kernel/reading/confined-file-name.ts deleted file mode 100644 index 7785265a8..000000000 --- a/cli/src/kernel/reading/confined-file-name.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { basename } from "node:path"; - -/** Whether `fileName` names exactly one entry directly inside the directory it will be joined - * to — never a walk out of it, the directory itself, or a smuggled absolute path. `join` - * normalises a `..` away visually yet still deletes where the result lands, so a name failing - * this is refused before it reaches `rm`. */ -export function isBareFileName(fileName: string): boolean { - if (fileName === "" || fileName === "." || fileName === "..") return false; - return basename(fileName) === fileName; -} diff --git a/cli/src/kernel/reading/home-dir.ts b/cli/src/kernel/reading/home-dir.ts index 8d2af2d53..4e51f9743 100644 --- a/cli/src/kernel/reading/home-dir.ts +++ b/cli/src/kernel/reading/home-dir.ts @@ -1,27 +1,11 @@ import { homedir as osHomedir } from "node:os"; -import { join } from "node:path"; /** `HOME` first, because `os.homedir()` never reads it on Windows: a `HOME` set under Git * Bash or by a test sandbox is ignored by a bare call there. Every site naming a tool's - * session files, the telemetry sink or the identity file resolves through here. */ + * session files resolves through here. */ export function resolveHomeDir( env: NodeJS.ProcessEnv = process.env, osHomedirFn: () => string = osHomedir ): string { return env.HOME || osHomedirFn(); } - -/** - * `/.config/aidd` on POSIX, `%APPDATA%/aidd` on Windows — the directory a - * *person's own choice* lives under, never a project's. It refuses `AIDD_USER_CONFIG_DIR` - * on purpose: a repository or a CI job can set that variable, so reaching the identity file - * through it would not be this person's own choice to make. The telemetry sink is - * deliberately not a caller — `TelemetrySinkAdapter` honours that variable itself, and - * falls back to a legacy POSIX-shaped directory on Windows. - */ -export function resolveAiddConfigDir(): string { - if (process.platform === "win32" && process.env.APPDATA) { - return join(process.env.APPDATA, "aidd"); - } - return join(resolveHomeDir(), ".config", "aidd"); -} diff --git a/cli/src/kernel/reading/json-file.ts b/cli/src/kernel/reading/json-file.ts index 28a2d4537..a71af103d 100644 --- a/cli/src/kernel/reading/json-file.ts +++ b/cli/src/kernel/reading/json-file.ts @@ -1,12 +1,3 @@ -/** A parsed JSON value narrowed to a plain object — `null`, an array or a primitive all - * answer `{}` rather than throwing, so a caller reads a missing or wrong-shaped field as - * absent instead of guarding the narrowing itself. */ -export function asPlainObjectOrEmpty(value: unknown): Record { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && "code" in error; } diff --git a/cli/src/kernel/reading/repository-root.ts b/cli/src/kernel/reading/repository-root.ts deleted file mode 100644 index 26c7d904f..000000000 --- a/cli/src/kernel/reading/repository-root.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; - -/** The checkout `start` sits in, or `start` itself when it sits in none — the anchor every - * run-journal path is relative to, never the directory a command was run from. Walked, not - * shelled to `git`: `.git` counts as a directory or as a worktree's `gitdir:` pointer file, - * and the walk stops at the filesystem root rather than climbing into a stranger's journal. */ -export function repositoryRootAbove(start: string): string { - let current = start; - for (;;) { - if (existsSync(join(current, ".git"))) return current; - const parent = dirname(current); - if (parent === current) return start; - current = parent; - } -} diff --git a/cli/src/presentation/commands/telemetry.ts b/cli/src/presentation/commands/telemetry.ts deleted file mode 100644 index 90b0ffffb..000000000 --- a/cli/src/presentation/commands/telemetry.ts +++ /dev/null @@ -1,317 +0,0 @@ -import type { Command } from "commander"; -import { toCostReportEnvelope } from "../../contexts/telemetry/domain/cost-report-envelope.js"; -import { - DEFAULT_REPORT_DAYS, - resolveReportPeriod, -} from "../../contexts/telemetry/domain/report-period.js"; -import { telemetryRemovalIsEmpty } from "../../contexts/telemetry/domain/telemetry-removal.js"; -import { createDeps } from "../../runtime/wiring/framework.js"; -import { ARTEFACT_AXES, buildCostReportArtefact } from "../display/cost-report-artefact.js"; -import { printCostReport } from "../display/cost-report-display.js"; -import { printTelemetryCheckReport } from "../display/telemetry-check-display.js"; -import { - printLocalCostReadReport, - printPersonIdentityLink, - printPersonIdentityOff, - printPersonIdentityStatus, - printPersonIdentityUnlink, - printPersonIdentityUse, - printTelemetryOffReport, - printTelemetryOnReport, - warnIfFiguresMoveTheTokenToo, -} from "../display/telemetry-display.js"; -import { - printTelemetryForgetPreview, - printTelemetryForgetRefused, - printTelemetryForgetResult, -} from "../display/telemetry-forget-display.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerTelemetryCommand(program: Command): void { - const telemetry = program - .command("telemetry") - .description("Control whether AIDD may measure this project"); - - telemetry - .command("on") - .description("Turn on the AIDD telemetry switch and git-ignore the run journal") - .option( - "--yes", - "Confirm writing the git-tracked switch — this turns measurement on for everyone who clones", - false - ) - .action(async (cmdOptions: { yes: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.telemetryOnUseCase.execute({ - projectRoot, - confirmed: cmdOptions.yes, - }); - printTelemetryOnReport(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); - - telemetry - .command("read") - .description( - "Read what sessions cost from the files their tools already wrote, with no process running" - ) - .option( - "--session ", - "One session to read. Omitted, every session the run journal knows is read" - ) - .action(async (cmdOptions: { session?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - warnIfFiguresMoveTheTokenToo(output, deps.telemetrySink); - const result = await deps.readLocalCostUseCase.execute({ - projectRoot, - env: process.env, - ...(cmdOptions.session === undefined ? {} : { sessionId: cmdOptions.session }), - }); - printLocalCostReadReport(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); - - registerTelemetryIdentityCommand(telemetry, program); - registerTelemetryCheckCommand(telemetry, program); - - telemetry - .command("report") - .description( - "Report what a period, or one task inside it, cost — tokens, models and steps, with how strongly each was attributed" - ) - .option("--from ", "First UTC day to report, as YYYY-MM-DD") - .option("--to ", "Last UTC day to report, as YYYY-MM-DD (default today)") - .option( - "--days ", - `How many days back to report, ending at --to (default ${DEFAULT_REPORT_DAYS})` - ) - .option( - "--task ", - "Restrict to the sessions that wrote into this task, as /" - ) - .option("--project ", "Restrict to this project") - .option("--step ", "Restrict to this step") - .option("--model ", "Restrict to this model") - .option("--tool ", "Restrict to this tool") - .option( - "--axis ", - `Print one axis as a table to paste elsewhere: ${ARTEFACT_AXES.join(" | ")}` - ) - .option("--json", "Print one object a program can parse, instead of text for a person") - .action( - async (cmdOptions: { - from?: string; - to?: string; - days?: string; - task?: string; - project?: string; - step?: string; - model?: string; - tool?: string; - axis?: string; - json?: boolean; - }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - // The clock is read once, here, and never again: everything downstream works from - // the two absolute days this resolves to, so the same call answers the same twice. - const period = resolveReportPeriod(cmdOptions, new Date()); - const deps = await createDeps(projectRoot, { verbose }, output); - warnIfFiguresMoveTheTokenToo(output, deps.telemetrySink); - const report = await deps.reportCostUseCase.execute({ - period, - projectRoot, - env: process.env, - ...(cmdOptions.task === undefined ? {} : { task: cmdOptions.task }), - filters: { - ...(cmdOptions.project === undefined ? {} : { project: cmdOptions.project }), - ...(cmdOptions.step === undefined ? {} : { step: cmdOptions.step }), - ...(cmdOptions.model === undefined ? {} : { model: cmdOptions.model }), - ...(cmdOptions.tool === undefined ? {} : { tool: cmdOptions.tool }), - }, - }); - // One value, three renderings, none deriving a figure the others cannot see: both - // `--json` and `--axis` read the envelope the terminal rendering is built from. - if (cmdOptions.json) output.print(JSON.stringify(toCostReportEnvelope(report), null, 2)); - else if (cmdOptions.axis !== undefined) - output.print(buildCostReportArtefact(toCostReportEnvelope(report), cmdOptions.axis)); - else printCostReport(output, report); - } catch (error) { - errorHandler.handle(error); - } - } - ); - - telemetry - .command("off") - .description( - "Turn off the AIDD telemetry switch, warning if a tool's own settings file still exports" - ) - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.telemetryOffUseCase.execute({ projectRoot }); - printTelemetryOffReport(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); - - telemetry - .command("forget") - .description( - "Irreversibly remove what this tool measured: this project's run journal, this " + - "machine's stored records, and this machine's identity file" - ) - .option( - "--yes", - "Confirm removal after seeing what would go — without it, nothing is removed", - false - ) - .action(async (cmdOptions: { yes: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const preview = await deps.forgetTelemetryUseCase.preview({ projectRoot }); - printTelemetryForgetPreview(output, preview); - if (telemetryRemovalIsEmpty(preview)) return; - if (!cmdOptions.yes) { - printTelemetryForgetRefused(output); - return; - } - const result = await deps.forgetTelemetryUseCase.remove(preview); - printTelemetryForgetResult(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); -} - -/** Whether the measurement chain is actually recording, not merely installed: a hook that - * fired, a session that closed, a tool's own files that can be read, and the two joining. */ -function registerTelemetryCheckCommand(telemetry: Command, program: Command): void { - telemetry - .command("check") - .description("Check whether the measurement chain is actually recording for this project") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.diagnoseTelemetryUseCase.execute({ - projectRoot, - env: process.env, - }); - printTelemetryCheckReport(output, result); - // A gated run judges nothing (measurement off, no repository), so it never fails - // the process; only a claim this run actually judged and found wanting does. - if (result.gate === undefined && result.claims.some((claim) => claim.verdict === "fail")) { - process.exitCode = 1; - } - } catch (error) { - errorHandler.handle(error); - } - }); -} - -/** Whether this person's own identifier is attached to what `aidd telemetry read` stores: - * never a project's choice, and never the `telemetry on`/`off` switch beside it. */ -function registerTelemetryIdentityCommand(telemetry: Command, program: Command): void { - const identity = telemetry - .command("identity") - .description("Whether this person's own identifier is attached to records read locally"); - // The bare noun is a question, so it answers with state rather than a help screen. - // `--help` still prints the help. - identity.action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityStatus(output, await deps.personIdentityUseCase.status()); - } catch (error) { - errorHandler.handle(error); - } - }); - - identity - .command("use [identifier]") - .description( - "Mint this person's identifier, or take one minted on another machine. --name attaches a display name" - ) - .option("--name ", "A display name for whichever identifier this call settles on") - .action(async (identifier: string | undefined, cmdOptions: { name?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityUse( - output, - await deps.personIdentityUseCase.use({ - ...(identifier === undefined ? {} : { identifier }), - ...(cmdOptions.name === undefined ? {} : { displayName: cmdOptions.name }), - }) - ); - } catch (error) { - errorHandler.handle(error); - } - }); - - identity - .command("off") - .description("Opt out: new records carry no person, from now on") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityOff(output, await deps.personIdentityUseCase.off()); - } catch (error) { - errorHandler.handle(error); - } - }); - - identity - .command("link ") - .description( - "Add an identifier this person cannot choose onto this same person - one row, not two, in a report" - ) - .action(async (rawIdentity: string) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityLink(output, await deps.personIdentityUseCase.link(rawIdentity)); - } catch (error) { - errorHandler.handle(error); - } - }); - - identity - .command("unlink ") - .description("Withdraw an added identifier from this person") - .action(async (rawIdentity: string) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityUnlink(output, await deps.personIdentityUseCase.unlink(rawIdentity)); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/presentation/display/cost-report-artefact.ts b/cli/src/presentation/display/cost-report-artefact.ts deleted file mode 100644 index d1ecc46a5..000000000 --- a/cli/src/presentation/display/cost-report-artefact.ts +++ /dev/null @@ -1,451 +0,0 @@ -import type { - CostReportEmptySelection, - CostReportFilterName, - CostReportFilters, -} from "../../contexts/telemetry/domain/cost-report.js"; -import { fromMicroUsd } from "../../contexts/telemetry/domain/cost-report.js"; -import type { - CostReportEnvelope, - CostReportEnvelopePersonRow, - CostReportEnvelopeTotals, -} from "../../contexts/telemetry/domain/cost-report-envelope.js"; -import { bareOrchestratingSkillNames } from "../../contexts/telemetry/domain/flow-attribution.js"; -import type { PersonResolution } from "../../contexts/telemetry/domain/person-resolution.js"; -import { getAiToolConfig } from "../../contexts/tools/domain/registry.js"; -import { - ATTRIBUTION_LABELS, - BACKLOG_DECLARATION_LABELS, - TASK_ATTRIBUTION_LABELS, - TASK_UNATTRIBUTED_LABELS, -} from "./cost-report-display.js"; - -/** One axis, as a markdown table a person pastes elsewhere: it drops the share column the - * terminal rendering adds, since a table leaving the session that made it carries figures and - * not a percentage of them. It reads the envelope, the shape a consumer already parses. */ -export const ARTEFACT_AXES = [ - "total", - "day", - "step", - "model", - "agent", - "prompt", - "task", - "backlog", - "flow", - "tool", - "project", - "person", -] as const; - -export type ArtefactAxis = (typeof ARTEFACT_AXES)[number]; - -const NO_PROMPT_LABEL = "no prompt named"; -const UNKNOWN_AMOUNT = "amount unknown"; -const NOTHING_MEASURED = "nothing in this period"; -const NOTHING_IN_SELECTION = "nothing in this selection"; -const SESSION_TOTAL_LABEL = "session total, not requests"; -const NO_KNOWN_PROJECT = "no known project"; -const NO_KNOWN_MODEL = "no known model"; -// Not "no agent": the main thread is where a session starts, not an absence. -const MAIN_THREAD = "the main thread"; -// A tool that names no agent has said nothing about which one ran, so calling that row "the -// main thread" would state a fact nothing observed. -const AGENT_NOT_STATED = "the tool names no agent"; -// Distinct on purpose: an unresolved row names a real but unplaced identity and repeats once -// per identity, while the no-identity row is singular. Neither is a shared bucket. -const NO_PERSON_IDENTIFIER = "no identity — nobody opted in"; -function unresolvedPersonLabel(identity: string): string { - return `unresolved — not mapped to anyone (${identity})`; -} - -const UNKNOWN_REASON: Partial> = { - task: "no journal has ever declared it or written into it", - tool: "it is not one of the tools this build knows", -}; - -function count(value: number): string { - return value.toLocaleString("en-US"); -} - -function amount(microUsd: number): string { - return `$${fromMicroUsd(microUsd).toFixed(2)}`; -} - -/** The four counters are disjoint on every reader here, so adding them counts nothing twice. */ -function envelopeTokens(totals: CostReportEnvelopeTotals): number { - return ( - (totals.input_tokens ?? 0) + - (totals.output_tokens ?? 0) + - (totals.cache_read_tokens ?? 0) + - (totals.cache_creation_tokens ?? 0) - ); -} - -function hasSelection(envelope: CostReportEnvelope): boolean { - return envelope.task !== undefined || envelope.filters !== undefined; -} - -function nothingLabel(envelope: CostReportEnvelope): string { - return hasSelection(envelope) ? NOTHING_IN_SELECTION : NOTHING_MEASURED; -} - -function figure(totals: CostReportEnvelopeTotals, envelope: CostReportEnvelope): string { - if (totals.requests === 0) return nothingLabel(envelope); - const cost = totals.cost_micro_usd === undefined ? UNKNOWN_AMOUNT : amount(totals.cost_micro_usd); - return `${cost} — ${count(envelopeTokens(totals))} tokens, ${count(totals.requests)} requests`; -} - -function filtersSuffix(filters: CostReportFilters | undefined): string { - if (!filters) return ""; - const parts = Object.entries(filters).map(([name, value]) => `${name}=${value}`); - return parts.length === 0 ? "" : `, filters: ${parts.join(", ")}`; -} - -/** Carried on every axis's own header, so a figure copied out of the session that made it - * stays placeable without the command that produced it. Never worded "measurement is off" - * bare: the sink is scoped to this person, not this project, so an off switch contradicts no - * figure beside it. */ -function measurementSuffix(envelope: CostReportEnvelope): string { - return envelope.measurement_enabled - ? "" - : " — this project's switch is off, figures are the whole sink, not scoped to it"; -} - -function header(envelope: CostReportEnvelope, axisLabel: string): string { - const { from_day, to_day } = envelope.period; - const task = envelope.task === undefined ? "" : `, task ${envelope.task}`; - return ( - `period ${from_day} to ${to_day}${task}${filtersSuffix(envelope.filters)} — axis: ${axisLabel}` + - measurementSuffix(envelope) - ); -} - -function unknownReason(filter: CostReportFilterName): string { - return UNKNOWN_REASON[filter] ?? `no record has ever named this ${filter}`; -} - -function emptySelectionMessage({ - filter, - value, - known, - combination, -}: CostReportEmptySelection): string { - if (!known) return `${filter} '${value}' matched nothing — ${unknownReason(filter)}`; - if (combination) - return `${filter} '${value}' matched nothing combined with the rest of this selection`; - return `${filter} '${value}' matched nothing in this selection — known, but no work here`; -} - -/** What the read could not do travels with what it did: a total assembled from a partial read - * is otherwise indistinguishable from a complete one. `identity_unusable === "absent"` is the - * ordinary default rather than damage, so only the person axis states it. */ -function caveats( - envelope: CostReportEnvelope, - { includeAbsentIdentityCaveat = false }: { includeAbsentIdentityCaveat?: boolean } = {} -): readonly string[] { - const lines: string[] = []; - if (envelope.empty_selection !== undefined) { - lines.push(emptySelectionMessage(envelope.empty_selection)); - } - if (envelope.read.undated_records > 0) { - lines.push( - `${count(envelope.read.undated_records)} records carry no moment and are in no period` - ); - } - if (envelope.read.unreadable_lines > 0) { - lines.push(`${count(envelope.read.unreadable_lines)} lines could not be read`); - } - if (envelope.read.identity_unusable === "unreadable") { - lines.push( - "this machine's own identity could not be read; every identifier is reported unresolved" - ); - } else if (envelope.read.identity_unusable === "absent" && includeAbsentIdentityCaveat) { - lines.push("no identity was declared; every identifier is reported unresolved"); - } - return lines; -} - -function table( - envelope: CostReportEnvelope, - axisLabel: string, - column: string, - rows: readonly string[] -): string { - return [ - header(envelope, axisLabel), - "", - `| ${column} | Total |`, - "| --- | --- |", - ...rows, - ...caveats(envelope), - ].join("\n"); -} - -/** One total, in a line: the answer to "what did this cost". */ -function totalArtefact(envelope: CostReportEnvelope): string { - return [ - header(envelope, "total"), - "", - figure(envelope.totals, envelope), - ...caveats(envelope), - ].join("\n"); -} - -/** Every day the period spans, gaps included and never capped the way a terminal caps a long - * series: dropping rows in a file is the false continuity the cap exists to prevent. */ -function dayArtefact(envelope: CostReportEnvelope): string { - return table( - envelope, - "by day", - "Day", - envelope.by_day.map((row) => `| ${row.day} | ${figure(row.totals, envelope)} |`) - ); -} - -/** Two rows can share one step name — the same skill reached from a tool's own statement and - * from a journal interval is two claims, never one — so this axis carries a third column: - * without it the pair is indistinguishable from one step double-counted. */ -function stepArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_step.map((row) => { - const step = row.step ?? "unattributed"; - return `| ${step} | ${ATTRIBUTION_LABELS[row.attribution]} | ${figure(row.totals, envelope)} |`; - }); - return [ - header(envelope, "by step"), - "", - "| Step | Attribution | Total |", - "| --- | --- | --- |", - ...rows, - ...caveats(envelope), - ].join("\n"); -} - -/** A third column for the same reason `stepArtefact` carries one: a named task's row rests on - * a closed interval and the table must say so. A row for what fell in no declared interval - * carries no attribution, only its reason. */ -function taskArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_task.map((row) => { - const task = row.task ?? (row.reason === undefined ? "" : TASK_UNATTRIBUTED_LABELS[row.reason]); - const strength = row.attribution === undefined ? "—" : TASK_ATTRIBUTION_LABELS[row.attribution]; - return `| ${task} | ${strength} | ${figure(row.totals, envelope)} |`; - }); - return [ - header(envelope, "by task"), - "", - "| Task | Attribution | Total |", - "| --- | --- | --- |", - ...rows, - ...caveats(envelope), - ].join("\n"); -} - -/** No third column, unlike `taskArtefact`: every named backlog row rests on the same single - * route, so there is no second strength to distinguish. */ -function backlogArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_backlog.map((row) => { - const name = - row.backlog ?? - (row.declaration !== undefined - ? BACKLOG_DECLARATION_LABELS[row.declaration] - : row.reason !== undefined - ? TASK_UNATTRIBUTED_LABELS[row.reason] - : ""); - return `| ${name} | ${figure(row.totals, envelope)} |`; - }); - return table(envelope, "by backlog", "Backlog item", rows); -} - -const OUTSIDE_EVERY_FLOW_LABEL = "outside any flow"; - -/** Standing properties of how a flow is read, never a damaged read the way `caveats()`'s lines - * are, which is why they are assembled apart. Each set is gated on a row it describes: a limit - * about a mechanism that never ran is noise. */ -function flowLimits(envelope: CostReportEnvelope): readonly string[] { - return [...journalFlowLimits(envelope), ...toolStatedFlowLimits(envelope)]; -} - -/** Both properties of walking the journal's own step sequence, so both are gated on a row - * that walk produced. */ -function journalFlowLimits(envelope: CostReportEnvelope): readonly string[] { - if (!envelope.by_flow.some((row) => row.attribution === "journal-interval")) return []; - return [ - "a skill run by hand while a flow was open is counted inside it: the orchestrator's own " + - "call and a person's write the identical step_start line", - `a skill of this project named ${orAny(bareOrchestratingSkillNames())} opens a flow of ` + - "its own: outside a plugin a host names a skill by its folder alone, and this axis " + - "has only that name to go on", - ]; -} - -/** A flow no interval bounded is a name, and a name cannot say how many runs it stands for: - * a reader taking it for one run reads its total as one orchestration's cost. */ -function toolStatedFlowLimits(envelope: CostReportEnvelope): readonly string[] { - if (!envelope.by_flow.some((row) => row.attribution === "tool-stated")) return []; - return [ - "a flow only a record's own tool named is every run of that skill at once: its journal " + - "opened no flow to bound one run from the next, so the row has no opening moment and " + - "its total is not one orchestration's", - ]; -} - -/** `a`, `a or b`, `a, b or c`: however many names there are, the sentence stays grammatical. */ -function orAny(names: readonly string[]): string { - if (names.length <= 1) return names[0] ?? ""; - return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`; -} - -/** Two extra columns, for the same reason `stepArtefact` carries one: two rows can share a - * `flow` name, and `Attribution` plus `Opened at` are what keep them from reading as one flow - * double-counted. A `tool-stated` row is a bucket with no single opening moment, so it prints - * an em dash there, as does work outside every flow. */ -function flowArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_flow.map((row) => { - const flow = row.flow ?? OUTSIDE_EVERY_FLOW_LABEL; - const openedAt = row.started_at ?? "—"; - const attribution = ATTRIBUTION_LABELS[row.attribution]; - return `| ${flow} | ${attribution} | ${openedAt} | ${figure(row.totals, envelope)} |`; - }); - return [ - header(envelope, "by flow"), - "", - "| Flow | Attribution | Opened at | Total |", - "| --- | --- | --- | --- |", - ...rows, - ...flowLimits(envelope), - ...caveats(envelope), - ].join("\n"); -} - -function agentArtefact(envelope: CostReportEnvelope): string { - return table( - envelope, - "by agent", - "Agent", - envelope.by_agent.map( - (row) => - `| ${row.agent ?? (row.attribution === "main-thread" ? MAIN_THREAD : AGENT_NOT_STATED)} | ${figure(row.totals, envelope)} |` - ) - ); -} - -/** The id alone is opaque; the moment its turn began is what a person greps for in their own - * transcript. `—` where a row carries none, never a moment borrowed from another turn. */ -function promptArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_prompt.map( - (row) => - `| ${row.prompt ?? NO_PROMPT_LABEL} | ${row.started_at ?? "—"} | ${figure(row.totals, envelope)} |` - ); - return [ - header(envelope, "by prompt"), - "", - "| Prompt | Started at | Total |", - "| --- | --- | --- |", - ...rows, - ...caveats(envelope), - ].join("\n"); -} - -function modelArtefact(envelope: CostReportEnvelope): string { - return table( - envelope, - "by model", - "Model", - envelope.by_model.map( - (row) => `| ${row.model ?? NO_KNOWN_MODEL} | ${figure(row.totals, envelope)} |` - ) - ); -} - -/** A mapped row's own label: its display name when one was set, its canonical identifier - * otherwise — never a raw identity, since a mapped row may carry several. */ -function mappedPersonLabel(row: CostReportEnvelopePersonRow): string { - return row.display_name ?? row.person ?? ""; -} - -/** A `Record` rather than an if-chain with a fallback, so a value added to `PersonResolution` - * fails to compile here instead of reaching a reader as "nobody opted in". */ -const PERSON_LABELS: Record string> = { - mapped: mappedPersonLabel, - // The same label a mapped row gets: it is the same person, and `resolution` already carries - // how it was reached. - "this-machine": mappedPersonLabel, - unresolved: (row) => unresolvedPersonLabel(row.identities[0] ?? ""), - none: () => NO_PERSON_IDENTIFIER, -}; - -function personLabel(row: CostReportEnvelopePersonRow): string { - return PERSON_LABELS[row.resolution](row); -} - -/** A third column, because an auditable person line carries its own evidence: the raw - * identities behind it, not only its label. */ -function personArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_person.map((row) => { - const identities = row.identities.length > 0 ? row.identities.join(", ") : "—"; - return `| ${personLabel(row)} | ${identities} | ${figure(row.totals, envelope)} |`; - }); - return [ - header(envelope, "by person"), - "", - "| Person | Identities | Total |", - "| --- | --- | --- |", - ...rows, - ...caveats(envelope, { includeAbsentIdentityCaveat: true }), - ].join("\n"); -} - -function projectArtefact(envelope: CostReportEnvelope): string { - return table( - envelope, - "by project", - "Project", - envelope.by_project.map( - (row) => `| ${row.project ?? NO_KNOWN_PROJECT} | ${figure(row.totals, envelope)} |` - ) - ); -} - -/** A tool that cannot be read is never a zero. One carrying only a session total prints that - * rather than "nothing in this period": measured, but not a sum of requests. */ -function toolArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_tool.map((row) => { - const because = row.reason ? ` — ${row.reason}` : ""; - let value: string; - if (row.coverage === "not-covered") { - value = `not covered${because}`; - } else if (row.totals.requests === 0 && row.session_totals) { - value = `${count(envelopeTokens(row.session_totals))} tokens (${SESSION_TOTAL_LABEL})${because}`; - } else { - value = `${figure(row.totals, envelope)}${because}`; - } - return `| ${getAiToolConfig(row.tool).displayName} | ${value} |`; - }); - return table(envelope, "by tool", "Tool", rows); -} - -const BUILDERS: Record string> = { - total: totalArtefact, - day: dayArtefact, - step: stepArtefact, - model: modelArtefact, - agent: agentArtefact, - prompt: promptArtefact, - task: taskArtefact, - backlog: backlogArtefact, - flow: flowArtefact, - tool: toolArtefact, - project: projectArtefact, - person: personArtefact, -}; - -export function isArtefactAxis(value: string): value is ArtefactAxis { - return (ARTEFACT_AXES as readonly string[]).includes(value); -} - -/** An unknown axis is refused by name, with the ones this knows, never guessed at. */ -export function buildCostReportArtefact(envelope: CostReportEnvelope, axis: string): string { - if (!isArtefactAxis(axis)) { - throw new Error(`Unknown axis '${axis}'. Expected one of: ${ARTEFACT_AXES.join(", ")}.`); - } - return BUILDERS[axis](envelope); -} diff --git a/cli/src/presentation/display/cost-report-display.ts b/cli/src/presentation/display/cost-report-display.ts deleted file mode 100644 index b72386e93..000000000 --- a/cli/src/presentation/display/cost-report-display.ts +++ /dev/null @@ -1,521 +0,0 @@ -import type { - CostReport, - CostReportAttributionRow, - CostReportBacklogRow, - CostReportDayRow, - CostReportEmptySelection, - CostReportFilterName, - CostReportFilters, - CostReportProjectRow, - CostReportStepRow, - CostReportTaskAttributionRow, - CostReportTaskRow, - CostReportToolRow, - CostTotals, -} from "../../contexts/telemetry/domain/cost-report.js"; -import { fromMicroUsd } from "../../contexts/telemetry/domain/cost-report.js"; -import type { StepAttributionSource } from "../../contexts/telemetry/domain/step-attribution.js"; -import type { - TaskAttributionSource, - TaskUnattributedReason, -} from "../../contexts/telemetry/domain/task-attribution.js"; -import { getAiToolConfig } from "../../contexts/tools/domain/registry.js"; -import type { CLIOutput } from "../output.js"; - -/** `unattributed` says nothing could attribute this, never that the work ran outside every - * step: on at least one measured tool the two are indistinguishable. */ -export const ATTRIBUTION_LABELS: Record = { - "tool-stated": "stated by the tool", - "prompt-matched": "matched on the prompt", - "journal-interval": "from a journal interval", - unattributed: "unattributed", -}; - -export const TASK_ATTRIBUTION_LABELS: Record = { - declared: "declared by the flow", - inferred: "inferred from a written file", -}; - -/** One label per reason, never one standing in for all of them. The wording keeps three kinds - * apart: a fact about the read ("no usable run journal", meaning none was attachable at all, - * against "no usable task declaration", meaning one was read and named nothing), a fact about - * a record's own age, and facts about how the work behaved. A resumed transcript's inherited - * turns were billed before this session opened a journal, so their row is worded as age and - * never as a complaint about declaring. */ -export const TASK_UNATTRIBUTED_LABELS: Record = { - "no-journal": "no usable run journal for this session", - "precedes-journal": "older than anything this session's journal witnessed", - "no-declaration": "no usable task declaration in this session", - "precedes-declaration": "before the next task this session declares", - "journal-silent": "the journal falls silent before this record", -}; - -/** A known task that names no item or whose declaration could not be parsed — distinct from - * `TASK_UNATTRIBUTED_LABELS`, which is about belonging to no task at all. */ -export const BACKLOG_DECLARATION_LABELS: Record<"none" | "unreadable", string> = { - none: "this task declares no backlog item", - unreadable: "this task's backlog declaration could not be read", -}; - -/** Never `$0.00`: a tool whose own files carry no amount has an unknown cost, not a free one. - * Exported so a second renderer prints the identical words. */ -export const UNKNOWN_AMOUNT = "amount unknown"; -/** Distinct from an unknown amount and from a zero: this one really did measure nothing. Not - * exported — outside readers go through `nothingLabel`, which picks between the two. */ -const NOTHING_MEASURED = "nothing in this period"; -/** The same zero under a narrowing selection: `task` and every filter cut the record set - * before any breakdown runs, so saying "period" there would be false about time. */ -const NOTHING_IN_SELECTION = "nothing in this selection"; -/** Never merged into the request-based figure beside it, and never called "cost" or - * "requests", being neither. */ -const SESSION_TOTAL_LABEL = "session total, not requests"; -const LABEL_WIDTH = 26; -const NO_KNOWN_PROJECT = "no known project"; -const NO_KNOWN_MODEL = "no known model"; -// Not "no agent": the main thread is where a session starts, not an absence. -const MAIN_THREAD = "the main thread"; -// Nor the main thread: a tool that names no agent has said nothing about which one ran. -const AGENT_NOT_STATED = "the tool names no agent"; - -/** What a row that names no agent is called, by which of the two silences it is. */ -export function agentRowLabel(row: CostReport["byAgents"][number]): string { - if (row.agent !== undefined) return row.agent; - return row.attribution === "main-thread" ? MAIN_THREAD : AGENT_NOT_STATED; -} - -// A prompt id is a uuid, wider than `LABEL_WIDTH`, and `padTo` never truncates. -const PROMPT_WIDTH = 38; -const NO_PROMPT = "no prompt named"; -// One row per turn, unbounded where every other axis has a small vocabulary. Truncated -// rather than suppressed the way `printDays` suppresses: a top N of a ranking is honest -// where a partial series is not, and the line below says how many were withheld. -const MAX_PRINTED_PROMPTS = 10; - -// A year by day is 365 rows: above this the text rendering names the count and points at -// --json rather than printing a screen nobody can scan. The envelope still carries them all. -const MAX_PRINTED_DAYS = 31; - -/** Exported alongside `formatAmount` and `totalTokens` so a second renderer formats the same - * figures through the same routine. */ -export function formatCount(value: number): string { - return value.toLocaleString("en-US"); -} - -export function formatAmount(microUsd: number): string { - return `$${fromMicroUsd(microUsd).toFixed(2)}`; -} - -/** The four counters are disjoint on every reader here — `input` excludes the cache figures — - * so adding them counts nothing twice. */ -export function totalTokens(totals: CostTotals): number { - return ( - (totals.inputTokens ?? 0) + - (totals.outputTokens ?? 0) + - (totals.cacheReadTokens ?? 0) + - (totals.cacheCreationTokens ?? 0) - ); -} - -/** Cost where the period has one, tokens where it does not, so a period of tools that carry - * no amount still breaks down. Named in the output, so nobody has to guess which. */ -export function shareBasis(totals: CostTotals): { readonly label: string; readonly of: number } { - return totals.costMicroUsd === undefined - ? { label: "of tokens", of: totalTokens(totals) } - : { label: "of cost", of: totals.costMicroUsd }; -} - -/** Exported for the same reason `shareBasis` is. */ -export function shareOf(totals: CostTotals, basis: number, useCost: boolean): string { - if (basis === 0) return " - "; - const part = useCost ? (totals.costMicroUsd ?? 0) : totalTokens(totals); - return `${Math.round((part / basis) * 100) - .toString() - .padStart(3)}%`; -} - -/** `padEnd` returns a longer string unchanged, so a label wider than the column runs straight - * into what follows it — a project id is a whole git remote. Exported so every column-padded - * reader of a label shares the one guarantee rather than each risking that collision. */ -export function padTo(label: string, width: number): string { - return label.length >= width ? `${label} ` : label.padEnd(width); -} - -function pad(label: string): string { - return padTo(label, LABEL_WIDTH); -} - -/** `task` and the generic filters both narrow the record set before any breakdown runs, so - * either one means every zero downstream is the selection talking, not the period. */ -function hasSelection(report: Pick): boolean { - return report.task !== undefined || report.filters !== undefined; -} - -/** Never a bare `0`, and never `NOTHING_MEASURED` under a selection, whose own narrowing is - * what emptied it. Exported so a second renderer tells the two absences apart the same way. */ -export function nothingLabel(report: Pick): string { - return hasSelection(report) ? NOTHING_IN_SELECTION : NOTHING_MEASURED; -} - -/** In the fixed order `cost-report.ts` gives them; empty for an unfiltered period. */ -function filtersSuffix(filters: CostReportFilters | undefined): string { - if (!filters) return ""; - const parts = Object.entries(filters).map(([name, value]) => `${name}=${value}`); - return parts.length === 0 ? "" : ` filters: ${parts.join(", ")}`; -} - -// `task` and `tool` are checked against journals and a declared list, never against a record, -// so "no record" would claim a check this layer never ran. -const UNKNOWN_REASON: Partial> = { - task: "no journal has ever declared it or written into it", - tool: "it is not one of the tools this build knows", -}; - -function unknownReason(filter: CostReportFilterName): string { - return UNKNOWN_REASON[filter] ?? `no record has ever named this ${filter}`; -} - -/** A period that genuinely holds no work never reaches here: an `emptySelection` is carried - * only when a filter, never the period, is what emptied it. */ -export function emptySelectionMessage({ - filter, - value, - known, - combination, -}: CostReportEmptySelection): string { - if (!known) return ` ${filter} '${value}' matched nothing — ${unknownReason(filter)}`; - if (combination) - return ` ${filter} '${value}' matched nothing combined with the rest of this selection`; - return ` ${filter} '${value}' matched nothing in this selection — known, but no work here`; -} - -/** Never a bare `0`, the same refusal `requests` makes below: a session count is no less a - * claim about what was measured. */ -export function sessionsFigure(report: CostReport): string { - return report.sessions === 0 ? nothingLabel(report) : formatCount(report.sessions); -} - -/** `0` where there are no tokens to divide, never `NaN`. `tokens` is a parameter because - * every caller already holds it, and this stays the one place the rounding happens. */ -export function cacheReadSharePercent(totals: CostTotals, tokens: number): number { - return tokens === 0 ? 0 : Math.round(((totals.cacheReadTokens ?? 0) / tokens) * 100); -} - -function printTotals(output: CLIOutput, report: CostReport): void { - const { totals } = report; - if (totals.requests === 0) { - output.print(` ${pad("sessions")}${sessionsFigure(report)}`); - output.print(` ${pad("requests")}${nothingLabel(report)}`); - return; - } - const tokens = totalTokens(totals); - const cacheShare = cacheReadSharePercent(totals, tokens); - output.print(` ${pad("sessions")}${sessionsFigure(report)}`); - output.print(` ${pad("requests")}${formatCount(totals.requests)}`); - output.print(` ${pad("tokens")}${formatCount(tokens)} ${cacheShare}% cache`); - output.print( - ` ${pad("cost")}${totals.costMicroUsd === undefined ? UNKNOWN_AMOUNT : formatAmount(totals.costMicroUsd)}` - ); - if (report.activeTimeSeconds !== undefined) { - const minutes = Math.round(report.activeTimeSeconds / 60); - output.print( - ` ${pad("active time")}${formatCount(minutes)} min per session; not attributable to steps` - ); - } -} - -function figureFor(totals: CostTotals, useCost: boolean): string { - if (!useCost) return `${formatCount(totalTokens(totals))} tokens`; - return totals.costMicroUsd === undefined ? UNKNOWN_AMOUNT : formatAmount(totals.costMicroUsd); -} - -function printStepRows( - output: CLIOutput, - rows: readonly CostReportStepRow[], - basis: number, - useCost: boolean -): void { - for (const row of rows) { - const name = row.step ?? ATTRIBUTION_LABELS.unattributed; - const strength = row.step === undefined ? "" : ` ${ATTRIBUTION_LABELS[row.attribution]}`; - output.print( - ` ${pad(name)}${shareOf(row.totals, basis, useCost)} ${figureFor(row.totals, useCost)}${strength}` - ); - } -} - -function printAttributionRows( - output: CLIOutput, - rows: readonly CostReportAttributionRow[], - basis: number, - useCost: boolean -): void { - for (const row of rows) { - output.print( - ` ${pad(ATTRIBUTION_LABELS[row.attribution])}${shareOf(row.totals, basis, useCost)}` - ); - } -} - -/** Every declared tool, including the ones that can say nothing: a tool missing from the list - * reads as one that did nothing, which for an unreadable tool is a false zero. */ -function printToolRows( - output: CLIOutput, - rows: readonly CostReportToolRow[], - report: Pick -): void { - for (const row of rows) { - const name = getAiToolConfig(row.tool).displayName; - if (row.coverage === "not-covered") { - output.print(` ${pad(name)}not covered${row.reason ? ` — ${row.reason}` : ""}`); - continue; - } - if (row.totals.requests === 0 && row.sessionTotals) { - const tokens = `${formatCount(totalTokens(row.sessionTotals))} tokens (${SESSION_TOTAL_LABEL})`; - output.print(` ${pad(name)}${tokens}${row.reason ? ` — ${row.reason}` : ""}`); - continue; - } - if (row.totals.requests === 0) { - output.print( - ` ${pad(name)}${nothingLabel(report)}${row.reason ? ` — ${row.reason}` : ""}` - ); - continue; - } - const figure = - row.totals.costMicroUsd === undefined - ? UNKNOWN_AMOUNT - : formatAmount(row.totals.costMicroUsd); - const tokens = `${formatCount(totalTokens(row.totals))} tokens`; - output.print(` ${pad(name)}${figure} ${tokens}${row.reason ? ` — ${row.reason}` : ""}`); - } -} - -function printCaveats(output: CLIOutput, report: CostReport): void { - if (report.undatedRecords > 0) { - output.print( - ` ${formatCount(report.undatedRecords)} records carry no moment and are in no period` - ); - } - if (report.unreadableLines > 0) { - output.print(` ${formatCount(report.unreadableLines)} lines could not be read`); - } -} - -/** An empty group prints nothing at all, never a heading over silence. */ -interface Basis { - readonly label: string; - readonly of: number; - readonly useCost: boolean; -} - -/** Only where `--task` narrowed the report: without one there is no per-record task identity - * to break down. */ -function printTaskAttribution(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.taskAttributionMix === undefined) return; - output.print(""); - output.print(` ticket known ${basis.label}`); - printTaskAttributionRows(output, report.taskAttributionMix, basis.of, basis.useCost); -} - -function printTaskAttributionRows( - output: CLIOutput, - rows: readonly CostReportTaskAttributionRow[], - basis: number, - useCost: boolean -): void { - for (const row of rows) { - output.print( - ` ${pad(TASK_ATTRIBUTION_LABELS[row.attribution])}${shareOf(row.totals, basis, useCost)}` - ); - } -} - -function printStepsAndAttribution(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.bySteps.length === 0) return; - output.print(""); - output.print(` by step ${basis.label}`); - printStepRows(output, report.bySteps, basis.of, basis.useCost); - output.print(""); - output.print(` attribution ${basis.label}`); - printAttributionRows(output, report.attributionMix, basis.of, basis.useCost); -} - -/** Straight after the steps, because it answers what they cannot: on a session that - * delegates, the step axis names a few percent and this one names the rest. */ -function printAgents(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.byAgents.length === 0) return; - output.print(""); - output.print(` by agent ${basis.label}`); - for (const row of report.byAgents) { - const name = agentRowLabel(row); - const share = shareOf(row.totals, basis.of, basis.useCost); - output.print( - ` ${padTo(name, LABEL_WIDTH)}${share} ${figureFor(row.totals, basis.useCost)}` - ); - } -} - -/** The one axis no host limit can empty: every record the transcript reader resolves carries - * a `prompt_id`, where a skill name, an identity and a declaration each may be missing. */ -function printPrompts(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.byPrompts.length === 0) return; - output.print(""); - output.print(` by prompt ${basis.label}`); - for (const row of report.byPrompts.slice(0, MAX_PRINTED_PROMPTS)) { - const share = shareOf(row.totals, basis.of, basis.useCost); - output.print( - ` ${padTo(row.prompt ?? NO_PROMPT, PROMPT_WIDTH)}${padTo(row.startedAt ?? "", 22)}${share} ${figureFor(row.totals, basis.useCost)}` - ); - } - const withheld = report.byPrompts.length - MAX_PRINTED_PROMPTS; - if (withheld > 0) { - output.print(` ${formatCount(withheld)} more prompts — see --json for all of them`); - } -} - -function printModels(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.byModels.length === 0) return; - output.print(""); - output.print(` by model ${basis.label}`); - for (const row of report.byModels) { - const name = row.model ?? NO_KNOWN_MODEL; - const share = shareOf(row.totals, basis.of, basis.useCost); - output.print(` ${pad(name)}${share} ${figureFor(row.totals, basis.useCost)}`); - } -} - -function printProjects( - output: CLIOutput, - rows: readonly CostReportProjectRow[], - basis: Basis -): void { - if (rows.length === 0) return; - output.print(""); - output.print(` by project ${basis.label}`); - for (const row of rows) { - const name = row.project ?? NO_KNOWN_PROJECT; - const share = shareOf(row.totals, basis.of, basis.useCost); - output.print(` ${pad(name)}${share} ${figureFor(row.totals, basis.useCost)}`); - } -} - -/** One row per task a record's moment fell inside, then one per reason present, in - * `TASK_UNATTRIBUTED_REASONS`' fixed order after every named task. The attribution sits beside - * a named row for the same reason `printStepRows` puts it there. */ -function printTasks(output: CLIOutput, rows: readonly CostReportTaskRow[], basis: Basis): void { - if (rows.length === 0) return; - output.print(""); - output.print(` by task ${basis.label}`); - for (const row of rows) { - const name = row.task ?? (row.reason === undefined ? "" : TASK_UNATTRIBUTED_LABELS[row.reason]); - const strength = - row.attribution === undefined ? "" : ` ${TASK_ATTRIBUTION_LABELS[row.attribution]}`; - output.print( - ` ${pad(name)}${shareOf(row.totals, basis.of, basis.useCost)} ${figureFor(row.totals, basis.useCost)}${strength}` - ); - } -} - -/** Named items, then the two rows for a known task that named none or could not be read, then - * the reason rows — the tail order `printTasks` uses. No attribution column: a backlog row - * rests on one route only. */ -function printBacklog( - output: CLIOutput, - rows: readonly CostReportBacklogRow[], - basis: Basis -): void { - if (rows.length === 0) return; - output.print(""); - output.print(` by backlog item ${basis.label}`); - for (const row of rows) { - const name = - row.backlog ?? - (row.declaration !== undefined - ? BACKLOG_DECLARATION_LABELS[row.declaration] - : row.reason !== undefined - ? TASK_UNATTRIBUTED_LABELS[row.reason] - : ""); - output.print( - ` ${pad(name)}${shareOf(row.totals, basis.of, basis.useCost)} ${figureFor(row.totals, basis.useCost)}` - ); - } -} - -/** Chronological, never sorted by size: a series read out of order is not a series. Past - * `MAX_PRINTED_DAYS` a count replaces the rows, since dropping some would be false - * continuity. */ -function printDays( - output: CLIOutput, - rows: readonly CostReportDayRow[], - report: Pick -): void { - if (rows.length === 0) return; - output.print(""); - output.print(" by day"); - if (rows.length > MAX_PRINTED_DAYS) { - output.print( - ` ${formatCount(rows.length)} days in this period — see --json for the daily breakdown` - ); - return; - } - for (const row of rows) { - if (row.totals.requests === 0) { - output.print(` ${pad(row.day)}${nothingLabel(report)}`); - continue; - } - const figure = - row.totals.costMicroUsd === undefined - ? UNKNOWN_AMOUNT - : formatAmount(row.totals.costMicroUsd); - output.print(` ${pad(row.day)}${figure} ${formatCount(totalTokens(row.totals))} tokens`); - } -} - -/** Prints no amount it was not given — the rates live outside this repository — and no - * prompt, code, diff or file path: a task appears by its identity alone. */ -function printHeader(output: CLIOutput, report: CostReport): void { - const scope = report.task === undefined ? "period" : `task ${report.task}`; - output.print(`${scope} ${report.fromDay} to ${report.toDay}${filtersSuffix(report.filters)}`); - // Only the off state is worth a line: with the switch on, every figure below already shows - // it working. Never worded "measurement is off for this project" — the sink is scoped to - // this person, so the figures can be real work from anywhere the switch was ever on, and a - // sentence claiming nothing was measured would contradict a genuine count beside it. - if (!report.measurementEnabled) { - output.print( - "this project's own switch is off — the figures below are not scoped to it, they are " + - "the whole sink; turn this project's measurement on with `aidd telemetry on`" - ); - } - output.print(""); - if (report.emptySelection !== undefined) { - output.print(emptySelectionMessage(report.emptySelection)); - output.print(""); - } -} - -// A filter-emptied selection has nothing to break down: every row would read "nothing in this -// period", the false zero this layer refuses. -function printBreakdowns(output: CLIOutput, report: CostReport): void { - const basis: Basis = { - ...shareBasis(report.totals), - useCost: report.totals.costMicroUsd !== undefined, - }; - printTaskAttribution(output, report, basis); - printStepsAndAttribution(output, report, basis); - printAgents(output, report, basis); - printPrompts(output, report, basis); - printModels(output, report, basis); - printProjects(output, report.byProjects, basis); - printTasks(output, report.byTasks, basis); - printBacklog(output, report.byBacklog, basis); - output.print(""); - output.print(" by tool"); - printToolRows(output, report.byTools, report); - printDays(output, report.byDays, report); -} - -export function printCostReport(output: CLIOutput, report: CostReport): void { - printHeader(output, report); - printTotals(output, report); - if (report.emptySelection === undefined) printBreakdowns(output, report); - printCaveats(output, report); -} diff --git a/cli/src/presentation/display/telemetry-check-display.ts b/cli/src/presentation/display/telemetry-check-display.ts deleted file mode 100644 index 74787c653..000000000 --- a/cli/src/presentation/display/telemetry-check-display.ts +++ /dev/null @@ -1,265 +0,0 @@ -import type { - DiagnoseTelemetryResult, - DiagnoseTelemetryUncoveredTool, -} from "../../contexts/telemetry/application/diagnose-telemetry-use-case.js"; -import { - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerManagerInstruction, - sessionTrailerManagerSnippet, -} from "../../contexts/telemetry/domain/formats/commit-session-trailer.js"; -import type { - TelemetryClaim, - TelemetryClaimId, - TelemetryClaimVerdict, -} from "../../contexts/telemetry/domain/telemetry-claim.js"; -import type { TelemetryExportLeftover } from "../../contexts/telemetry/domain/telemetry-export-leftover.js"; -import type { - TelemetryAllowedSetup, - TelemetryCommitTrailerSetup, - TelemetryHostRegistrationSetup, - TelemetryIdentitySetup, - TelemetryPluginVersionSetup, - TelemetryRecorderDeclarationSetup, - TelemetrySetup, -} from "../../contexts/telemetry/domain/telemetry-setup.js"; -import type { HostRegistrationAnswer } from "../../contexts/tools/domain/host-plugin-registration.js"; -import type { CLIOutput } from "../output.js"; - -const LABEL_WIDTH = 22; - -// The only names a check report prints, now that the plugin's own `diagnose.cjs` these were -// once pinned to word for word is gone: changing one changes every report and nothing else. -const CLAIM_LABELS: Record = { - "hook-fired": "hook fired", - "session-journalled": "session journalled", - "tool-files-readable": "tool files readable", - "records-join": "records join", -}; - -const VERDICT_TOKENS: Record = { - ok: "ok", - fail: "FAIL", - unknown: "--", -}; - -function pad(label: string): string { - return label.padEnd(LABEL_WIDTH); -} - -// A sentence naming where a fact came from, never the claims' `ok`/`FAIL`/`--` column: that -// vocabulary is reserved for a grade, and nothing here is graded. -function printSetupRow(output: CLIOutput, label: string, detail: string): void { - output.print(` ${pad(label)}${detail}`); -} - -function describeAllowed(allowed: TelemetryAllowedSetup): string { - if (!allowed.readable) return `could not be read — ${allowed.location}`; - if (allowed.decidedBy === "person-refusal") { - return `no — this person's own refusal (${allowed.location})`; - } - return `${allowed.allowed ? "yes" : "no"} — ${allowed.location}`; -} - -function describeIdentity(identity: TelemetryIdentitySetup): string { - if (!identity.readable) return `could not be read — ${identity.path}`; - return `${identity.attached ? "yes" : "no"} — ${identity.path}`; -} - -function indentedPaths(paths: readonly string[]): string { - return paths.map((path) => `\n ${path}`).join(""); -} - -function describeRecorderDeclaration(declaration: TelemetryRecorderDeclarationSetup): string { - if (declaration.declared) return `yes — ${declaration.declaredAt.join(", ")}`; - if (declaration.unreadable.length > 0) { - return `could not be read — ${declaration.unreadable.join(", ")}`; - } - // The row a person reads to go add the declaration somewhere, so each candidate gets its - // own line rather than five absolute paths comma-joined into one. - return `nowhere this build checks — looked in:${indentedPaths(declaration.locationsChecked)}`; -} - -/** Read back out of the journal rather than re-derived, so it can only say what the hook - * itself said. `"unrecorded"` is the one real problem — a hook that ran and could not name - * its own build, which happens when the plugin was copied in by neither install route. */ -function describePluginVersion(plugin: TelemetryPluginVersionSetup): string { - if (plugin.kind === "recorded") return `${plugin.version} (as the hook recorded it)`; - if (plugin.kind === "nothing-journalled") return "no session journalled yet"; - return ( - "unknown — no journalled session names one. The plugin's own manifest was not beside " + - "its hooks and no `aidd` install recorded it; `aidd plugin install aidd-telemetry` " + - "would make it known." - ); -} - -/** Whether the host will act on the declaration. One line per plugin, since the answer is - * genuinely per plugin, ordered so what must be fixed comes first: a reader who stops after - * one line has still read the problem. Nothing installed is a healthy answer and says so. */ -function describeHostRegistration(registration: TelemetryHostRegistrationSetup): string { - if (registration.manifestUnreadable !== undefined) { - return `AIDD's own manifest could not be read — ${registration.manifestUnreadable}`; - } - const entries = registration.entries; - if (entries.length === 0) return "no plugin recorded for any tool"; - // Keyed on the answer type, never `string`: a fifth answer must fail to compile here rather - // than sort silently last, below the ones that are fine. - const rank: Record = { - "not-registered": 0, - "registered-disabled": 1, - unanswerable: 2, - registered: 3, - }; - const ordered = [...entries].sort((a, b) => rank[a.answer] - rank[b.answer]); - // A sentence first, then the lines. Every other setup row leads with one, and a label - // followed by padding and a newline reads as a value the command failed to produce. - const trouble = ordered.filter((entry) => entry.answer !== "registered").length; - const headline = - trouble === 0 - ? `all ${ordered.length} will load` - : `${trouble} of ${ordered.length} will not load, or could not be answered`; - return ordered.reduce( - (text, entry) => - `${text}\n ${entry.tool}/${entry.plugin}: ${entry.answer} — ${entry.detail}`, - headline - ); -} - -/** Leads with the only fact about the chain rather than its parts — how many recent commits - * carry it — so one line is the answer and the pieces below it are the why. */ -function describeCommitTrailer(trailer: TelemetryCommitTrailerSetup): string { - // Outside a repository there is nothing to say about hooks; "nothing installed" would - // describe a repository this project is not in. - if (trailer.hooksDirMissing === "no-repository") { - return "no repository here, so no hook to carry it"; - } - // A git that rejects `--git-path` still has a history, and the count is the fact that - // matters: saying "no repository" would replace one true fact with a false one. - if (trailer.hooksDir === undefined) { - return `${describeTrailerCount(trailer)} — git could not say where it runs hooks from`; - } - - // The manager comes from a root marker file, never from reading the hook, and under one - // `callSite: "missing"` is the ordinary shape rather than a fault. - if (trailer.hookManager !== undefined) return describeManagedCommitTrailer(trailer); - - const parts: string[] = []; - if (trailer.delegate === "absent") parts.push("nothing installed to write it"); - if (trailer.delegate === "not-executable") { - parts.push("its script is not executable, so git will not run it"); - } - if (trailer.callSite === "missing") parts.push("prepare-commit-msg does not call it"); - if (trailer.hookExecutable === false) { - parts.push("prepare-commit-msg is not executable, so git ignores it"); - } - if (trailer.callSite === "no-hook-file") parts.push("there is no prepare-commit-msg"); - // Said, never named. Which tool owns the file changes nothing a person does about it, and - // naming one would be a guess read out of its contents. - if (trailer.hookHasOtherContent) parts.push("that hook is somebody else's too"); - - return `${describeTrailerCount(trailer)}${parts.length === 0 ? "" : ` — ${parts.join("; ")}`}\n hooks run from ${trailer.hooksDir}`; -} - -/** The row for a repository lefthook or husky owns: wired reports the chain through the - * manager, not wired prints the job to add. Neither reads `callSite: "missing"` as a fault — - * that field describes an absolute-path line a manager never calls the delegate through. */ -function describeManagedCommitTrailer(trailer: TelemetryCommitTrailerSetup): string { - const manager = trailer.hookManager; - if (manager === undefined) throw new Error("describeManagedCommitTrailer needs a manager"); - const count = describeTrailerCount(trailer); - if (trailer.managerCallsDelegate === true) { - // Half the chain: the delegate still has to be there and executable. Reporting "wired" - // from `managerCallsDelegate` alone calls a checkout healthy where `telemetry on` never - // ran, leaving a reader no reason to run the one command that fixes it. - if (trailer.delegate !== "executable") { - const state = - trailer.delegate === "absent" - ? "nothing installed to write it" - : "its script is not executable, so git will not run it"; - return `${count} — wired through ${manager}, but ${state}; run \`aidd telemetry on\`\n hooks run from ${trailer.hooksDir}`; - } - return `${count} — wired through ${manager}'s own prepare-commit-msg\n hooks run from ${trailer.hooksDir}`; - } - const { targetFile, snippet } = sessionTrailerManagerSnippet( - manager, - SESSION_TRAILER_DELEGATE_FILE - ); - return `${count} — ${manager} owns prepare-commit-msg here; ${sessionTrailerManagerInstruction(manager, targetFile)}:\n${snippet}\n hooks run from ${trailer.hooksDir}`; -} - -/** A commit no session made carries no trailer by design, so a number below the total is not - * itself a fault though a bare "4 of 20" reads as one. The qualifier is added exactly when it - * could mislead: some commits carrying it, every part in place. */ -function describeTrailerCount(trailer: TelemetryCommitTrailerSetup): string { - const carried = trailer.recentlyCarrying; - if (carried === undefined) return "no commit history to read"; - const count = `${carried.carrying} of the last ${carried.examined} commits carry it`; - const everyPartWorks = - trailer.delegate === "executable" && - (trailer.callSite === "present" || trailer.managerCallsDelegate === true); - // `> 0`, never `>= 0`: zero with every part in place is the finding this row exists to - // surface, and must never be excused as by-design. - const someCarry = carried.carrying > 0 && carried.carrying < carried.examined; - if (!everyPartWorks || !someCarry) return count; - return `${count} — a commit no session made carries none, by design`; -} - -function printSetup(output: CLIOutput, setup: TelemetrySetup): void { - printSetupRow(output, "measurement allowed", describeAllowed(setup.allowed)); - printSetupRow(output, "identity attached", describeIdentity(setup.identity)); - printSetupRow( - output, - "records kept at", - `${setup.recordsLocation.path} (override with AIDD_TELEMETRY_DIR)` - ); - printSetupRow( - output, - "recorder declared", - describeRecorderDeclaration(setup.recorderDeclaration) - ); - printSetupRow(output, "plugins registered", describeHostRegistration(setup.hostRegistration)); - printSetupRow(output, "commit trailer", describeCommitTrailer(setup.commitTrailer)); - printSetupRow(output, "cli version", setup.versions.cli); - printSetupRow(output, "plugin version", describePluginVersion(setup.versions.plugin)); - output.print(""); -} - -function printClaim(output: CLIOutput, claim: TelemetryClaim): void { - const label = pad(CLAIM_LABELS[claim.claim]); - const verdict = VERDICT_TOKENS[claim.verdict].padEnd(4); - output.print(` ${label}${verdict} ${claim.detail}`); -} - -function printUncovered(output: CLIOutput, uncovered: DiagnoseTelemetryUncoveredTool): void { - const label = pad(`not covered: ${uncovered.tool}`); - output.print(` ${label}${"--".padEnd(4)} ${uncovered.reason}`); -} - -// Never a claim: a stale export lives in a tool's own settings file, nothing the hook, the -// journal or a reader can see, so it is warned on stderr rather than folded into the health -// count. -function printLeftoverExportConfig( - output: CLIOutput, - leftovers: readonly TelemetryExportLeftover[] -): void { - for (const leftover of leftovers) { - output.warn( - `${leftover.path} still sets ${leftover.keys.join(", ")} — delete these keys from ` + - "its `env` block by hand to stop that export; nothing here can do it for you." - ); - } -} - -export function printTelemetryCheckReport( - output: CLIOutput, - result: DiagnoseTelemetryResult -): void { - printSetup(output, result.setup); - if (result.gate !== undefined) { - output.print(` ${result.gate}`); - printLeftoverExportConfig(output, result.leftoverExportConfig); - return; - } - for (const claim of result.claims) printClaim(output, claim); - for (const uncovered of result.uncovered) printUncovered(output, uncovered); - printLeftoverExportConfig(output, result.leftoverExportConfig); -} diff --git a/cli/src/presentation/display/telemetry-display.ts b/cli/src/presentation/display/telemetry-display.ts deleted file mode 100644 index 24148e2db..000000000 --- a/cli/src/presentation/display/telemetry-display.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { - PersonIdentityLinkResult, - PersonIdentityOffResult, - PersonIdentityStatusResult, - PersonIdentityUnlinkResult, - PersonIdentityUseResult, -} from "../../contexts/telemetry/application/person-identity-use-case.js"; -import type { - LocalCostToolStatus, - ReadLocalCostResult, -} from "../../contexts/telemetry/application/read-local-cost-use-case.js"; -import type { TelemetryOffResult } from "../../contexts/telemetry/application/telemetry-off-use-case.js"; -import type { TelemetryOnResult } from "../../contexts/telemetry/application/telemetry-on-use-case.js"; -import type { TelemetrySink } from "../../contexts/telemetry/domain/ports/telemetry-sink.js"; -import { getAiToolConfig } from "../../contexts/tools/domain/registry.js"; -import type { CLIOutput } from "../output.js"; - -const LOCAL_COST_STATUS_LABELS: Record = { - found: "read", - empty: "read, nothing found", - // Never "nothing found": this tool has no trace of the session, so it can say nothing - // about what it cost. Printing the two alike would let a session read as free. - "not-found": "no session found", - // Its reader failed, so nothing is known about this tool for this session and something - // is wrong. Distinct from "no session found", where nothing is known and nothing is wrong. - unreadable: "could not be read", - "not-covered": "not covered", - // Never "no session found": the journal named another tool, so this reader never ran. - // Worded for a whole sweep, where a session-shaped label would claim too much. - "not-asked": "no session read belongs to it", -}; - -export function printTelemetryOnReport(output: CLIOutput, result: TelemetryOnResult): void { - const switchLabel = result.switchChanged ? "on" : "already on"; - output.success(`AIDD telemetry: ${switchLabel} (${result.switchPath})`); - output.info(`${result.switchPath} is git-tracked — this applies to everyone who clones.`); -} - -function printLocalCostToolLine( - output: CLIOutput, - report: ReadLocalCostResult["toolReports"][number] -): void { - const name = getAiToolConfig(report.tool).displayName; - const label = LOCAL_COST_STATUS_LABELS[report.status]; - const counts = - report.status === "found" ? ` (${report.recordsStored} new of ${report.recordsFound})` : ""; - const reason = report.reason ? ` — ${report.reason}` : ""; - // Never folded into the status: a tool that read most sessions and failed one reports - // as read, and a failure visible only in the status would vanish exactly there. - const failures = - report.sessionsFailed > 0 - ? ` [${report.sessionsFailed} session${report.sessionsFailed === 1 ? "" : "s"} could not be read: ${report.failureReason}]` - : ""; - output.print(` ${name}: ${label}${counts}${reason}${failures}`); -} - -export function printLocalCostReadReport(output: CLIOutput, result: ReadLocalCostResult): void { - // A refusal reads nothing and stores nothing — see ReadLocalCostUseCase's own doc — so - // it is told apart here from "no session journalled yet", which is a fact about the - // journal, not about whether the sweep was allowed to run at all. - if (result.refusedReason !== undefined) { - output.print(` ${result.refusedReason}`); - return; - } - // One line per tool, never one per tool per session: twenty sessions across five tools is - // a hundred lines nobody reads. The session count leads, being the fact that changes. - const yielded = result.sessions.filter((session) => - session.toolReports.some((report) => report.recordsFound > 0) - ).length; - if (result.sessions.length === 0) { - output.print(" No session journalled yet — nothing to read."); - return; - } - output.print( - ` ${result.sessions.length} session${result.sessions.length === 1 ? "" : "s"} read, ${yielded} with records` - ); - for (const report of result.toolReports) printLocalCostToolLine(output, report); -} - -export function printTelemetryOffReport(output: CLIOutput, result: TelemetryOffResult): void { - const switchLabel = result.switchChanged ? "off" : "already off"; - output.success(`AIDD telemetry: ${switchLabel} (${result.switchPath})`); - output.info( - "This stops new recording only — sessions already journalled stay in aidd_docs/runs/ " + - "and whatever `aidd telemetry read` already stored, and `aidd telemetry report` still " + - "reports them. Run `aidd telemetry forget` to remove what was already measured." - ); -} - -const ORIGIN_LABELS: Record<"minted" | "adopted", string> = { - minted: "minted on this machine", - adopted: "taken from another machine", -}; - -const DECLARATION_DISCLAIMER = - "This is a declaration the tool cannot check - it never verifies who is running it."; - -function identityLabel(result: PersonIdentityStatusResult): string { - if (result.identity === null) return "off - records carry no person"; - const name = result.identity.displayName ? `, display name "${result.identity.displayName}"` : ""; - return `on, ${result.identity.personId} (${ORIGIN_LABELS[result.identity.origin]})${name} (${result.filePath})`; -} - -export function printPersonIdentityStatus( - output: CLIOutput, - result: PersonIdentityStatusResult -): void { - output.print(`AIDD identity: ${identityLabel(result)}`); - if (result.identity !== null && result.identity.alsoMe.length > 0) { - output.print(` Identifiers added onto this person: ${result.identity.alsoMe.join(", ")}`); - } -} - -/** One outcome word, three sentences: minted discloses what it attaches to, adopted says what - * happened to what it replaced, and unchanged must not claim anything was written. */ -export function printPersonIdentityUse(output: CLIOutput, result: PersonIdentityUseResult): void { - const at = `(${result.filePath})`; - if (result.outcome === "unchanged") { - // "already in effect" is true of the identifier and false of the file when a name came - // with the call: something was written, and the first line must not say otherwise. - const alsoNamed = result.displayNameSet === undefined ? "" : ", display name set"; - output.success( - `AIDD identity: ${result.identity.personId} already in effect${alsoNamed} ${at}` - ); - } else if (result.outcome === "minted") { - output.success(`AIDD identity: on, ${result.identity.personId} ${at}`); - output.print(" Attaches to: records this machine reads locally, from now on."); - output.print( - " Never attaches to: the run journal, a session already recorded, or a tool's own export." - ); - } else { - const replaced = - result.replacedPersonId === undefined ? "" : ` (replacing ${result.replacedPersonId})`; - output.success(`AIDD identity: now ${result.identity.personId}${replaced} ${at}`); - if (result.replacedPersonId !== undefined) { - output.print(" Records already written keep the identifier they were written with."); - } - output.print(` ${DECLARATION_DISCLAIMER}`); - } - if (result.displayNameSet !== undefined) { - output.print(` Display name: ${result.displayNameSet}`); - } -} - -export function printPersonIdentityOff(output: CLIOutput, result: PersonIdentityOffResult): void { - if (!result.removed) { - output.success("AIDD identity: already off - nothing to withdraw"); - return; - } - output.success(`AIDD identity: off (${result.filePath} removed)`); - if (result.discardedDamaged) { - output.print( - " The identity file could not be read, so it was discarded rather than left behind." - ); - } - output.print(" New records carry no person, from now on."); - output.print( - " Records already stored keep the identifier they were written with - none are changed." - ); - output.print(" Opting in again later mints a fresh identifier, never this one back."); - output.print( - ` ${result.addedIdentifiersRemoved} added identifier${result.addedIdentifiersRemoved === 1 ? "" : "s"} removed with it.` - ); -} - -export function printPersonIdentityLink(output: CLIOutput, result: PersonIdentityLinkResult): void { - if (result.alreadyListed) { - output.success( - `AIDD identity: '${result.identity}' is already listed under ${result.personId} (${result.filePath})` - ); - return; - } - output.success( - `AIDD identity: linked '${result.identity}' to ${result.personId} (${result.filePath})` - ); - output.print(` ${DECLARATION_DISCLAIMER}`); -} - -export function printPersonIdentityUnlink( - output: CLIOutput, - result: PersonIdentityUnlinkResult -): void { - if (!result.removed) { - output.success(`AIDD identity: '${result.identity}' was not listed - nothing to remove`); - return; - } - output.success(`AIDD identity: unlinked '${result.identity}' (${result.filePath})`); -} - -/** Says, once per command that touches the figures, that this machine locates them through a - * variable which also moves its GitHub token — a second effect nothing else tells anyone - * about. `warn` writes to stderr, so a `--json` caller's stdout stays one parseable object. */ -export function warnIfFiguresMoveTheTokenToo(output: CLIOutput, sink: TelemetrySink): void { - if (sink.locatedBy !== "user-config-dir") return; - output.warn( - `Figures are kept at ${sink.rootDir}, located through AIDD_USER_CONFIG_DIR — which also ` + - "moves auth.json, this machine's GitHub token. If that directory is shared, the token " + - "is in it. Set AIDD_TELEMETRY_DIR to the same path instead: it moves the figures and " + - "nothing else." - ); -} diff --git a/cli/src/presentation/display/telemetry-forget-display.ts b/cli/src/presentation/display/telemetry-forget-display.ts deleted file mode 100644 index e7a9321d4..000000000 --- a/cli/src/presentation/display/telemetry-forget-display.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { - TelemetryRemovalFailure, - TelemetryRemovalOutcome, - TelemetryRemovalResult, -} from "../../contexts/telemetry/application/forget-telemetry-use-case.js"; -import type { - TelemetryHistoryReading, - TelemetryMachineIdentityRemoval, - TelemetryRemovalPreview, -} from "../../contexts/telemetry/domain/telemetry-removal.js"; -import { telemetryRemovalIsEmpty } from "../../contexts/telemetry/domain/telemetry-removal.js"; -import type { CLIOutput } from "../output.js"; - -function identityPreviewLine(identity: TelemetryMachineIdentityRemoval): string { - if (!identity.present) return ` This machine's identity (${identity.path}): nothing to remove`; - const damaged = identity.unreadable - ? " — present but could not be read; will still be removed" - : ""; - return ` This machine's identity (${identity.path}): 1 file${damaged}`; -} - -function fileList(files: readonly string[]): string { - return files.map((file) => ` ${file}`).join("\n"); -} - -function committedHistoryWarning(files: readonly string[]): string { - return ( - "Cannot be reached: this project's run journal has been committed, so git history " + - `certainly holds it. Tracked right now:\n${fileList(files)}\n` + - "Removing it from the working tree does not remove it from history. No command here " + - "rewrites git history." - ); -} - -function stagedHistoryWarning(files: readonly string[]): string { - return ( - "Cannot be reached, not yet: this project's run journal is staged (tracked by git " + - `right now) but has never been committed — history does not hold it yet:\n${fileList(files)}\n` + - "The staged copy stays in git's index after this removal deletes the working-tree " + - "file, so a later `git commit` with nothing further done would put it back. No " + - "command here touches git's index or history." - ); -} - -const POSSIBLE_HISTORY_WARNING = - "Cannot be reached: this project's run journal is not tracked by git right now, but " + - "history may still hold it if it was ever committed before — that cannot be told " + - "apart from never having been committed. No command here rewrites git history."; - -const NO_REPOSITORY_HISTORY_WARNING = - "This project is not a git repository, so no history holds this project's run journal."; - -function historyWarning(history: TelemetryHistoryReading): string { - if (history.certainty === "committed") return committedHistoryWarning(history.files); - if (history.certainty === "staged") return stagedHistoryWarning(history.files); - if (history.certainty === "possible") return POSSIBLE_HISTORY_WARNING; - return NO_REPOSITORY_HISTORY_WARNING; -} - -function printHistory(output: CLIOutput, history: TelemetryHistoryReading): void { - output.warn(historyWarning(history)); -} - -/** What `aidd telemetry forget` would remove, and what it never can — shown before - * anything is asked to go. */ -export function printTelemetryForgetPreview( - output: CLIOutput, - preview: TelemetryRemovalPreview -): void { - if (telemetryRemovalIsEmpty(preview)) { - output.print( - "AIDD telemetry: nothing was ever measured here — this project's journal, this " + - "machine's stored records and this machine's identity are all already empty. " + - "Nothing to remove." - ); - printHistory(output, preview.history); - return; - } - output.print("This would remove:"); - output.print( - ` This project's run journal (${preview.journal.path}): ` + - `${preview.journal.runFileNames.length} run file(s)` - ); - output.print( - ` This machine's stored records — every project measured on this machine ` + - `(${preview.sink.path}): ${preview.sink.dayFileNames.length} day file(s)` - ); - output.print(identityPreviewLine(preview.identity)); - printHistory(output, preview.history); -} - -/** The refusal a person who looked and decided not to sees — never an error. */ -export function printTelemetryForgetRefused(output: CLIOutput): void { - output.print("Nothing removed. Pass --yes to remove exactly what is listed above."); -} - -function printFailures( - output: CLIOutput, - label: string, - failures: readonly TelemetryRemovalFailure[] -): void { - for (const failure of failures) { - output.warn(`Could not remove ${label} ${failure.path} — ${failure.reason}`); - } -} - -function outcomeLine(label: string, outcome: TelemetryRemovalOutcome): string { - const failedNote = - outcome.failed.length > 0 ? `, ${outcome.failed.length} could not be removed` : ""; - return ` ${label}: ${outcome.removed} removed${failedNote}`; -} - -/** What went, and what did not — in counts a person can check against - * `printTelemetryForgetPreview`'s own counts. */ -export function printTelemetryForgetResult( - output: CLIOutput, - result: TelemetryRemovalResult -): void { - output.success("AIDD telemetry: removed"); - output.print(outcomeLine("This project's run journal", result.journal)); - output.print(outcomeLine("This machine's stored records", result.sink)); - output.print(outcomeLine("This machine's identity", result.identity)); - printFailures(output, "journal run file", result.journal.failed); - printFailures(output, "sink day file", result.sink.failed); - printFailures(output, "identity file", result.identity.failed); - printHistory(output, result.history); - output.print( - "The telemetry switch (.aidd/config.json) was not touched — measurement can be turned " + - "on again with `aidd telemetry on`." - ); -} diff --git a/cli/src/runtime/filesystem/file-adapter.ts b/cli/src/runtime/filesystem/file-adapter.ts index 52c6a2b99..0e5ebbdd2 100644 --- a/cli/src/runtime/filesystem/file-adapter.ts +++ b/cli/src/runtime/filesystem/file-adapter.ts @@ -1,15 +1,5 @@ import { constants } from "node:fs"; -import { - access, - chmod, - mkdir, - readdir, - readFile, - realpath, - rm, - rmdir, - stat, -} from "node:fs/promises"; +import { access, mkdir, readdir, readFile, realpath, rm, rmdir, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; import { JsonParseError } from "../../kernel/errors.js"; @@ -135,10 +125,6 @@ export class FileAdapter implements FileReader, FileWriter, FileMerger { await rm(path, { recursive: true, force: true }); } - async chmodExecutable(path: string): Promise { - await chmod(path, 0o755); - } - async listFilesRecursive(dirPath: string): Promise { const results: string[] = []; await this.collectAbsolutePaths(dirPath, results); diff --git a/cli/src/runtime/git/git-adapter.ts b/cli/src/runtime/git/git-adapter.ts deleted file mode 100644 index c54ca7d59..000000000 --- a/cli/src/runtime/git/git-adapter.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { join, resolve } from "node:path"; -import { - SESSION_TRAILER_HOOK_HEADER, - sessionTrailerHookLine, -} from "../../contexts/telemetry/domain/formats/commit-session-trailer.js"; -import type { - CommitMessageDelegateInstall, - CommitMessageDelegateRemoval, - VersionControl, -} from "../../contexts/telemetry/domain/ports/version-control.js"; -import { - detectHookManager, - HOOK_MANAGER_MARKER_NAMES, - type HookManager, - HUSKY_MARKER_NAME, - LEFTHOOK_MARKER_NAMES, - type TelemetryCommitTrailerSetup, -} from "../../contexts/telemetry/domain/telemetry-setup.js"; -import type { FileReader } from "../../kernel/ports/file-reader.js"; -import type { FileWriter } from "../../kernel/ports/file-writer.js"; -import { environmentWithoutGitVariables } from "./git-environment.js"; - -const PREPARE_COMMIT_MSG_HOOK = "prepare-commit-msg"; - -export class GitAdapter implements VersionControl { - constructor(private readonly fs: FileReader & FileWriter) {} - - async installCommitMessageDelegate( - projectRoot: string, - delegateFile: string, - script: string - ): Promise { - const managerFacts = await this.detectHookManagerFacts(projectRoot, delegateFile); - // A manager that owns prepare-commit-msg regenerates it from its own config on every - // install, silently wiping any line appended here — so nothing is appended. The delegate - // still lands in `resolveDelegateDir`'s manager-aware directory, the fixed location the - // printed job resolves against at commit time. - if (managerFacts.hookManager !== undefined) { - const commonHooksDir = await this.resolveDelegateDir(projectRoot, managerFacts.hookManager); - // Outside a git repository there is nowhere for the delegate to land and nothing for a - // hand-added job to resolve against, so reporting `hookManager` would print a job that - // can never run. - if (commonHooksDir === null) return { lineAdded: false }; - await this.writeDelegate(commonHooksDir, join(commonHooksDir, delegateFile), script); - return { lineAdded: false, ...managerFacts }; - } - - const hooksDir = await this.resolveDelegateDir(projectRoot, undefined); - if (hooksDir === null) return { lineAdded: false }; - - const delegatePath = join(hooksDir, delegateFile); - await this.writeDelegate(hooksDir, delegatePath, script); - const lineAdded = await this.callDelegateFromHook( - hooksDir, - sessionTrailerHookLine(delegatePath) - ); - return { lineAdded }; - } - - async removeCommitMessageDelegate( - projectRoot: string, - delegateFile: string - ): Promise { - // `off` must look wherever `on` actually wrote: `resolveHooksDir` alone diverges the - // moment `core.hooksPath` does, and would report nothing removed on a project that has - // something to remove. - const managerFacts = await this.detectHookManagerFacts(projectRoot, delegateFile); - const hooksDir = await this.resolveDelegateDir(projectRoot, managerFacts.hookManager); - if (hooksDir === null) return { removed: false, ...managerFacts }; - - const delegatePath = join(hooksDir, delegateFile); - const lineDropped = await this.stopCallingDelegate( - hooksDir, - sessionTrailerHookLine(delegatePath) - ); - const fileDeleted = await this.deleteDelegate(delegatePath); - // Either half alone counts as something removed: a hand-edited hook, or a hand-deleted - // delegate, leaves the other behind, and "nothing to remove" there is unactionable. - return { removed: lineDropped || fileDeleted, ...managerFacts }; - } - - /** Rewritten on every install, never only when absent, so an older CLI's delegate is - * brought up to date. This file is ours outright, unlike the hook that calls it. */ - private async writeDelegate(hooksDir: string, delegatePath: string, script: string) { - await this.fs.createDirectory(hooksDir); - await this.fs.writeFile(delegatePath, script); - await this.fs.chmodExecutable(delegatePath); - } - - /** An existing hook is kept whole and gains a line at the end; only a repository with no - * hook at all gets one written from scratch. */ - private async callDelegateFromHook(hooksDir: string, line: string): Promise { - const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); - const existing = (await this.fs.fileExists(hookPath)) - ? await this.fs.readFile(hookPath) - : `${SESSION_TRAILER_HOOK_HEADER}\n`; - if (existing.includes(line)) return false; - - const separator = existing.endsWith("\n") ? "" : "\n"; - await this.fs.writeFile(hookPath, `${existing}${separator}${line}\n`); - await this.fs.chmodExecutable(hookPath); - return true; - } - - /** Drops that one line and leaves every other byte of the hook alone. */ - private async stopCallingDelegate(hooksDir: string, line: string): Promise { - const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); - if (!(await this.fs.fileExists(hookPath))) return false; - - const content = await this.fs.readFile(hookPath); - if (!content.includes(line)) return false; - - const kept = content.split("\n").filter((entry) => entry.trim() !== line); - const executable = await this.fs.isExecutable(hookPath); - await this.fs.writeFile(hookPath, kept.join("\n")); - if (executable) await this.fs.chmodExecutable(hookPath); - return true; - } - - private async deleteDelegate(delegatePath: string): Promise { - if (!(await this.fs.fileExists(delegatePath))) return false; - await this.fs.deleteFile(delegatePath); - return true; - } - - // A non-zero exit or a thrown spawn error both read as "not a repository", never a throw: - // `aidd telemetry check` gates on this before judging anything else. - async isRepository(cwd: string): Promise { - try { - const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { - cwd, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - return result.status === 0 && result.stdout.trim() !== ""; - } catch { - return false; - } - } - - // A non-zero exit — no repository, or git itself missing — reads as "nothing tracked", - // never a throw: turning telemetry on must not depend on being inside a git repository. - async listTrackedFiles(repoRoot: string, pathspec: string): Promise { - try { - const result = spawnSync("git", ["ls-files", "--", pathspec], { - cwd: repoRoot, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - if (result.status !== 0) return []; - return result.stdout.split("\n").filter((line) => line.trim() !== ""); - } catch { - return []; - } - } - - // `git log` on a pathspec, not `git ls-files`: the index and history are different - // questions, and this asks the second. A zero-commit repository and one where `pathspec` - // was only ever staged both read as no history, the honest answer for both. - async hasHistoryFor(repoRoot: string, pathspec: string): Promise { - try { - const result = spawnSync("git", ["log", "--oneline", "-1", "--", pathspec], { - cwd: repoRoot, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - return result.status === 0 && result.stdout.trim() !== ""; - } catch { - return false; - } - } - - /** Each field is answered independently: an unresolvable hooks directory leaves the file - * facts absent rather than guessed, and unreadable history leaves the count absent rather - * than zero, which is also what a genuinely broken install looks like. */ - async readCommitTrailerSetup( - projectRoot: string, - delegateFile: string, - trailerToken: string, - limit: number - ): Promise { - const hooksDir = await this.resolveHooksDir(projectRoot); - const recentlyCarrying = this.countCommitsCarrying(projectRoot, trailerToken, limit); - const history = recentlyCarrying === null ? {} : { recentlyCarrying }; - const managerFacts = await this.detectHookManagerFacts(projectRoot, delegateFile); - // The delegate is checked wherever `install` wrote it — `resolveDelegateDir`'s - // manager-aware answer, never `hooksDir`, from which husky's layout diverges. - const delegateDir = await this.resolveDelegateDir(projectRoot, managerFacts.hookManager); - if (hooksDir === null) { - return { - ...(await this.withoutHooksDir(projectRoot, delegateDir, delegateFile)), - ...managerFacts, - ...history, - }; - } - return { - ...(await this.hookFacts(hooksDir, delegateDir, delegateFile)), - hooksDir, - ...managerFacts, - ...history, - }; - } - - /** Decided from root marker names alone, never from the hook's contents, which a manager - * regenerates from its own config on every install. Both config files are read, never written. */ - private async detectHookManagerFacts( - projectRoot: string, - delegateFile: string - ): Promise> { - const presentMarkers: string[] = []; - for (const name of HOOK_MANAGER_MARKER_NAMES) { - if (await this.fs.fileExists(join(projectRoot, name))) presentMarkers.push(name); - } - const hookManager = detectHookManager(presentMarkers); - if (hookManager === undefined) return {}; - - const configPath = this.managerConfigPath(projectRoot, hookManager, presentMarkers); - const config = await this.readIfPresent(configPath); - // A mention, not a parse: a config naming the delegate anywhere, a comment included, - // reads as wired — the one case this cannot tell apart from a real call. - const managerCallsDelegate = config?.includes(delegateFile) ?? false; - return { hookManager, managerCallsDelegate }; - } - - private managerConfigPath( - projectRoot: string, - manager: HookManager, - presentMarkers: readonly string[] - ): string { - if (manager === "husky") return join(projectRoot, HUSKY_MARKER_NAME, PREPARE_COMMIT_MSG_HOOK); - const lefthookFile = - presentMarkers.find((name) => (LEFTHOOK_MARKER_NAMES as readonly string[]).includes(name)) ?? - LEFTHOOK_MARKER_NAMES[0]; - return join(projectRoot, lefthookFile); - } - - /** The one home install, removal and check all resolve through, so the three can never - * point at different directories. Under a manager, `core.hooksPath` is deliberately ignored - * — husky routes it under `.husky/`, the file this CLI must never write — and the fixed - * common-dir location is what a hand-added job resolves against at commit time. */ - private async resolveDelegateDir( - projectRoot: string, - hookManager: HookManager | undefined - ): Promise { - return hookManager !== undefined - ? this.gitDirVia(projectRoot, ["rev-parse", "--git-common-dir"], true) - : this.gitDirVia(projectRoot, ["rev-parse", "--git-path", "hooks"], false); - } - - /** `null` on any failure to run or to answer: installing or reading a hook is never - * allowed to be the reason a command fails. */ - private async gitDirVia( - projectRoot: string, - args: readonly string[], - appendHooks: boolean - ): Promise { - try { - const result = spawnSync("git", [...args], { - cwd: projectRoot, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - if (result.status !== 0) return null; - const answer = result.stdout.trim(); - if (answer === "") return null; - const resolved = resolve(projectRoot, answer); - return appendHooks ? join(resolved, "hooks") : resolved; - } catch { - return null; - } - } - - /** Which of the two causes left no hooks directory, asked rather than assumed: only a - * project outside git means "no hook to carry anything". `delegateDir` is asked - * independently and can still answer when `hooksDir` cannot, so whether the delegate is - * actually there — the one fact a person can act on — is not lost with it. */ - private async withoutHooksDir( - projectRoot: string, - delegateDir: string | null, - delegateFile: string - ): Promise> { - const inRepository = await this.isRepository(projectRoot); - return { - delegate: - delegateDir === null ? "absent" : await this.delegateState(join(delegateDir, delegateFile)), - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: inRepository ? "unresolved" : "no-repository", - }; - } - - private async hookFacts( - hooksDir: string, - delegateDir: string | null, - delegateFile: string - ): Promise> { - const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); - const line = sessionTrailerHookLine(join(hooksDir, delegateFile)); - const hook = await this.readIfPresent(hookPath); - return { - delegate: - delegateDir === null ? "absent" : await this.delegateState(join(delegateDir, delegateFile)), - // The hook's own bit, not the delegate's. Git refuses to run a `prepare-commit-msg` it - // cannot execute; reported rather than fixed, since the repair must never quietly widen - // a file this project did not write. - ...(hook === null ? {} : { hookExecutable: await this.fs.isExecutable(hookPath) }), - callSite: callSiteState(hook, line), - hookHasOtherContent: holdsSomebodyElsesLines(hook, line), - }; - } - - /** Git will not run a hook it cannot execute, so a delegate that is there but unrunnable - * is a distinct answer from one that is missing. */ - private async delegateState(path: string): Promise { - if (!(await this.fs.fileExists(path))) return "absent"; - return (await this.fs.isExecutable(path)) ? "executable" : "not-executable"; - } - - private async readIfPresent(path: string): Promise { - return (await this.fs.fileExists(path)) ? await this.fs.readFile(path) : null; - } - - /** `%(trailers:key=…)` is git's own reader, so this agrees with `git log` by construction - * rather than by a regex of ours. `null`, never `0`, when there is no history to read: a - * repository with no commits and one whose every commit is unstamped are different facts. */ - private countCommitsCarrying( - projectRoot: string, - trailerToken: string, - limit: number - ): { carrying: number; examined: number } | null { - try { - const result = spawnSync( - "git", - [ - "log", - `-${limit}`, - // The delegate refuses merges by design, so counting them would put commits in the - // denominator that can never be in the numerator. - "--no-merges", - `--format=%(trailers:key=${trailerToken},valueonly)%x00`, - ], - { cwd: projectRoot, encoding: "utf8", env: environmentWithoutGitVariables() } - ); - if (result.status !== 0) return null; - // No guard for an empty list: `git log` exits non-zero in a repository with no commits, - // so the branch above already answers `null`, and an all-merge repository cannot exist. - const commits = result.stdout.split("\u0000").slice(0, -1); - return { - carrying: commits.filter((one) => one.trim() !== "").length, - examined: commits.length, - }; - } catch { - return null; - } - } - - /** Asked of git rather than assembled from `.git`: `--git-path hooks` returns - * `core.hooksPath` when one is set — under which a hook written to `.git/hooks` is silently - * never run — and a linked worktree's *common* git dir, which is where git looks. The - * answer can be relative, and git prints it against the cwd, here `projectRoot`. */ - private async resolveHooksDir(projectRoot: string): Promise { - return this.gitDirVia(projectRoot, ["rev-parse", "--git-path", "hooks"], false); - } -} - -function callSiteState(hook: string | null, line: string): TelemetryCommitTrailerSetup["callSite"] { - if (hook === null) return "no-hook-file"; - return hook.includes(line) ? "present" : "missing"; -} - -/** `#!/bin/sh` alone is the file the CLI writes when a repository had none, so a hook - * holding only that is still ours. */ -function holdsSomebodyElsesLines(hook: string | null, line: string): boolean { - if (hook === null) return false; - return hook - .split("\n") - .map((entry) => entry.trim()) - .some((entry) => entry !== "" && entry !== line && entry !== SESSION_TRAILER_HOOK_HEADER); -} diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 97f3acc5c..9ce6d0e19 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -103,7 +103,6 @@ import type { CredentialStore } from "../auth/ports/credential-store.js"; import { atomicWriteFile } from "../filesystem/atomic-write.js"; import { FileAdapter } from "../filesystem/file-adapter.js"; import { HasherAdapter } from "../filesystem/hasher-adapter.js"; -import { GitAdapter } from "../git/git-adapter.js"; import { HttpClient } from "../http/http-client.js"; import { PlatformAdapter } from "../platform/platform-adapter.js"; import { InquirerPrompterAdapter, SilentPrompterAdapter } from "../prompter/prompter-adapter.js"; @@ -115,7 +114,6 @@ import { SelfUpdateUseCase } from "../self-update/self-update-use-case.js"; import { SelfUpdaterAdapter } from "../self-update/self-updater-adapter.js"; import { userConfigDir } from "../user-config-dir.js"; import { wireDistribution } from "./distribution.js"; -import { type TelemetryDeps, wireTelemetry } from "./telemetry.js"; import { wireTools } from "./tools.js"; import { createFrameworkBuildUseCase } from "./translate.js"; @@ -124,7 +122,7 @@ interface GlobalOptions { token?: string; } -interface Deps extends TelemetryDeps { +interface Deps { fs: FileReader & FileWriter & FileMerger; manifestRepo: ManifestRepository; /** `--scope user`'s own manifest repository: `userConfigDir()/manifest.json`, never nested @@ -355,7 +353,6 @@ export async function createDeps( nativeSources ); const gitignoreUseCase = new GitignoreUseCase(fs); - const git = new GitAdapter(fs); const postInstallPipelineUseCase = new PostInstallPipelineUseCase(manifestRepo, gitignoreUseCase); const installRuntimeConfigUseCase = new InstallRuntimeConfigUseCase( fs, @@ -586,17 +583,7 @@ export async function createDeps( const doctorAllUseCase = new DoctorAllUseCase(doctorUseCase); const listInstalledRulesUseCase = new ListInstalledRulesUseCase(fs); const checkUpdateUseCase = new CheckUpdateUseCase(cliUpdater, currentVersionProvider, logger, fs); - const telemetry = wireTelemetry({ - fs, - logger, - git, - projectRoot, - gitignoreUseCase, - currentVersionProvider, - manifestRepo, - }); const deps: Deps = { - ...telemetry, fs, manifestRepo, userManifestRepo, diff --git a/cli/src/runtime/wiring/installed-plugins-from-manifest.ts b/cli/src/runtime/wiring/installed-plugins-from-manifest.ts deleted file mode 100644 index f882a460b..000000000 --- a/cli/src/runtime/wiring/installed-plugins-from-manifest.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ManifestRepository } from "../../contexts/framework/domain/ports/manifest-repository.js"; -import type { - InstalledPluginRef, - InstalledPluginsReader, -} from "../../contexts/telemetry/domain/ports/installed-plugins-reader.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../kernel/tool.js"; - -/** In the composition root because it belongs to neither side: telemetry states what it - * needs, framework keeps the record, and either context translating for the other would be - * the reach into another's vocabulary the two ports exist to stop. */ -export function installedPluginsFromManifest(repo: ManifestRepository): InstalledPluginsReader { - return { - path: repo.path, - read: async () => { - const manifest = await repo.load(); - if (manifest === null) return null; - const byTool = new Map(); - for (const tool of AI_TOOL_IDS) { - const plugins = manifest.getPlugins(tool); - if (plugins.length === 0) continue; - byTool.set( - tool, - plugins.map((plugin) => ({ name: plugin.name, marketplace: plugin.marketplace })) - ); - } - return byTool; - }, - }; -} diff --git a/cli/src/runtime/wiring/telemetry.ts b/cli/src/runtime/wiring/telemetry.ts deleted file mode 100644 index d3e640211..000000000 --- a/cli/src/runtime/wiring/telemetry.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { GitignoreUseCase } from "../../contexts/framework/application/gitignore-use-case.js"; -import type { ManifestRepository } from "../../contexts/framework/domain/ports/manifest-repository.js"; -import { DiagnoseTelemetryUseCase } from "../../contexts/telemetry/application/diagnose-telemetry-use-case.js"; -import { ForgetTelemetryUseCase } from "../../contexts/telemetry/application/forget-telemetry-use-case.js"; -import { PersonIdentityUseCase } from "../../contexts/telemetry/application/person-identity-use-case.js"; -import { ReadLocalCostUseCase } from "../../contexts/telemetry/application/read-local-cost-use-case.js"; -import { ReportCostUseCase } from "../../contexts/telemetry/application/report-cost-use-case.js"; -import { TelemetryOffUseCase } from "../../contexts/telemetry/application/telemetry-off-use-case.js"; -import { TelemetryOnUseCase } from "../../contexts/telemetry/application/telemetry-on-use-case.js"; -import { createClaudeCodeTranscriptAccumulator } from "../../contexts/telemetry/domain/formats/claude-code-transcript.js"; -import { createCodexRolloutAccumulator } from "../../contexts/telemetry/domain/formats/codex-rollout.js"; -import type { SessionCostReader } from "../../contexts/telemetry/domain/ports/session-cost-reader.js"; -import type { TelemetrySink } from "../../contexts/telemetry/domain/ports/telemetry-sink.js"; -import type { VersionControl } from "../../contexts/telemetry/domain/ports/version-control.js"; -import { CopilotCostReaderAdapter } from "../../contexts/telemetry/infrastructure/copilot-cost-reader-adapter.js"; -import { HookTrustReaderAdapter } from "../../contexts/telemetry/infrastructure/hook-trust-reader-adapter.js"; -import { OpencodeCostReaderAdapter } from "../../contexts/telemetry/infrastructure/opencode-cost-reader-adapter.js"; -import { PersonIdentityAdapter } from "../../contexts/telemetry/infrastructure/person-identity-adapter.js"; -import { RunJournalReaderAdapter } from "../../contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; -import { TaskBacklogAdapter } from "../../contexts/telemetry/infrastructure/task-backlog-adapter.js"; -import { TelemetryEvidenceAdapter } from "../../contexts/telemetry/infrastructure/telemetry-evidence-adapter.js"; -import { TelemetrySinkAdapter } from "../../contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; -import { TranscriptCostReaderAdapter } from "../../contexts/telemetry/infrastructure/transcript-cost-reader-adapter.js"; -import { CLAUDE_CODE_TRANSCRIPT_LOCATION } from "../../contexts/tools/domain/profiles/claude/claude-transcript-location.js"; -import { CODEX_ROLLOUT_LOCATION } from "../../contexts/tools/domain/profiles/codex/codex-transcript-location.js"; -import { hostPluginRegistryReaders } from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; -import type { FileReader } from "../../kernel/ports/file-reader.js"; -import type { FileWriter } from "../../kernel/ports/file-writer.js"; -import type { Logger } from "../../kernel/ports/logger.js"; -import type { VersionReader } from "../../kernel/ports/version-reader.js"; -import { resolveHomeDir } from "../../kernel/reading/home-dir.js"; -import type { AiToolId } from "../../kernel/tool.js"; -import { installedPluginsFromManifest } from "./installed-plugins-from-manifest.js"; - -export interface TelemetryWiringShared { - fs: FileReader & FileWriter; - logger: Logger; - git: VersionControl; - projectRoot: string; - gitignoreUseCase: GitignoreUseCase; - currentVersionProvider: VersionReader; - manifestRepo: ManifestRepository; -} - -export interface TelemetryDeps { - telemetrySink: TelemetrySink; - telemetryOnUseCase: TelemetryOnUseCase; - telemetryOffUseCase: TelemetryOffUseCase; - readLocalCostUseCase: ReadLocalCostUseCase; - personIdentityUseCase: PersonIdentityUseCase; - diagnoseTelemetryUseCase: DiagnoseTelemetryUseCase; - reportCostUseCase: ReportCostUseCase; - forgetTelemetryUseCase: ForgetTelemetryUseCase; -} - -/** Tool identifiers appear here because a profile cannot name the adapter that reads it - * without putting infrastructure in the domain. `resolveHomeDir()` rather than a bare - * `homedir()`: on Windows the bare call ignores a `HOME` a person or a test sandbox set. */ -export function wireTelemetry(shared: TelemetryWiringShared): TelemetryDeps { - const { fs, logger, git, projectRoot, gitignoreUseCase, currentVersionProvider, manifestRepo } = - shared; - const telemetryEvidence = new TelemetryEvidenceAdapter(); - const telemetrySink = new TelemetrySinkAdapter(); - const runJournalReader = new RunJournalReaderAdapter(projectRoot); - const personIdentity = new PersonIdentityAdapter(); - - // The one place allowed to map a tool that declares `telemetryLocalRead: { kind: - // "declared" }` to the adapter that reads it. - const localCostReaders: ReadonlyMap = new Map< - AiToolId, - SessionCostReader - >([ - ["opencode", new OpencodeCostReaderAdapter()], - [ - "claude", - new TranscriptCostReaderAdapter( - resolveHomeDir(), - CLAUDE_CODE_TRANSCRIPT_LOCATION, - createClaudeCodeTranscriptAccumulator - ), - ], - [ - "codex", - new TranscriptCostReaderAdapter( - resolveHomeDir(), - CODEX_ROLLOUT_LOCATION, - createCodexRolloutAccumulator - ), - ], - ["copilot", new CopilotCostReaderAdapter(resolveHomeDir())], - ]); - - const readLocalCostUseCase = new ReadLocalCostUseCase( - telemetrySink, - localCostReaders, - runJournalReader, - personIdentity, - telemetryEvidence, - currentVersionProvider, - logger - ); - - return { - telemetrySink, - telemetryOnUseCase: new TelemetryOnUseCase(fs, logger, gitignoreUseCase, git, telemetrySink), - telemetryOffUseCase: new TelemetryOffUseCase(fs, logger, telemetryEvidence, git), - readLocalCostUseCase, - personIdentityUseCase: new PersonIdentityUseCase(personIdentity), - diagnoseTelemetryUseCase: new DiagnoseTelemetryUseCase( - telemetryEvidence, - git, - runJournalReader, - localCostReaders, - new HookTrustReaderAdapter(), - personIdentity, - telemetrySink, - currentVersionProvider, - installedPluginsFromManifest(manifestRepo), - hostPluginRegistryReaders() - ), - reportCostUseCase: new ReportCostUseCase( - telemetrySink, - runJournalReader, - personIdentity, - telemetryEvidence, - new TaskBacklogAdapter(projectRoot), - logger, - readLocalCostUseCase - ), - forgetTelemetryUseCase: new ForgetTelemetryUseCase( - telemetrySink, - runJournalReader, - personIdentity, - git - ), - }; -} diff --git a/cli/tests/architecture/catches-that-swallow.arch.test.ts b/cli/tests/architecture/catches-that-swallow.arch.test.ts index c47484b03..376edfeff 100644 --- a/cli/tests/architecture/catches-that-swallow.arch.test.ts +++ b/cli/tests/architecture/catches-that-swallow.arch.test.ts @@ -13,10 +13,6 @@ const BASELINE: Readonly> = { "src/contexts/tools/domain/ports/native-marketplace-source-reader.ts", "src/contexts/tools/domain/ports/schema-validator.ts", "src/contexts/tools/domain/ports/host-plugin-registry-reader.ts", - // What a host's registry says about an installed plugin: telemetry's diagnostic and - // `doctor` both need the comparison. + // What a host's registry says about an installed plugin: `doctor` needs the comparison. "src/contexts/tools/domain/host-plugin-registration.ts", "src/contexts/tools/domain/ports/host-marketplace-registry-reader.ts", // Whether a name a host's registry holds points at a different source: the sync-time @@ -49,37 +48,6 @@ const PUBLIC_MODULES: Readonly> = { // translate substitutes it, the diagnostic looks for it in what was installed. "src/contexts/tools/domain/formats/plugin-root-token.ts", ], - // Every entry is reached by `presentation` or by the composition root, and none is an - // adapter: what telemetry needs elsewhere it declares as its own port instead, so - // measurement reaches into no context and no context reaches into it. - telemetry: [ - // the six use cases the `telemetry` command drives - "src/contexts/telemetry/application/telemetry-on-use-case.ts", - "src/contexts/telemetry/application/telemetry-off-use-case.ts", - "src/contexts/telemetry/application/read-local-cost-use-case.ts", - "src/contexts/telemetry/application/report-cost-use-case.ts", - "src/contexts/telemetry/application/diagnose-telemetry-use-case.ts", - "src/contexts/telemetry/application/forget-telemetry-use-case.ts", - "src/contexts/telemetry/application/person-identity-use-case.ts", - // the shapes a rendered answer is made of - "src/contexts/telemetry/domain/cost-report.ts", - "src/contexts/telemetry/domain/cost-report-envelope.ts", - // how a person id was resolved, printed beside each row - "src/contexts/telemetry/domain/person-resolution.ts", - "src/contexts/telemetry/domain/report-period.ts", - "src/contexts/telemetry/domain/telemetry-removal.ts", - "src/contexts/telemetry/domain/telemetry-claim.ts", - "src/contexts/telemetry/domain/telemetry-setup.ts", - "src/contexts/telemetry/domain/telemetry-export-leftover.ts", - "src/contexts/telemetry/domain/flow-attribution.ts", - "src/contexts/telemetry/domain/step-attribution.ts", - "src/contexts/telemetry/domain/task-attribution.ts", - // the trailer a commit carries, written by the git adapter that installs the hook - "src/contexts/telemetry/domain/formats/commit-session-trailer.ts", - // ports a caller wires a concrete adapter into - "src/contexts/telemetry/domain/ports/telemetry-sink.ts", - "src/contexts/telemetry/domain/ports/version-control.ts", - ], translate: [ // the canonical shapes framework produces and translate consumes "src/contexts/translate/domain/canon.ts", diff --git a/cli/tests/architecture/context-self-reentry.arch.test.ts b/cli/tests/architecture/context-self-reentry.arch.test.ts index 51a249124..97cc3a21c 100644 --- a/cli/tests/architecture/context-self-reentry.arch.test.ts +++ b/cli/tests/architecture/context-self-reentry.arch.test.ts @@ -69,7 +69,7 @@ describe("the guard itself", () => { expect( climbsOutAndReenters("../framework/translator/plugin-translator.js", "framework", 5) ).toBe(false); - expect(climbsOutAndReenters("../domain/ports/version-control.js", "telemetry", 4)).toBe(false); + expect(climbsOutAndReenters("../domain/ports/version-control.js", "acme", 4)).toBe(false); }); it("ignores a specifier that does not climb at all", () => { @@ -80,7 +80,7 @@ describe("the guard itself", () => { expect( climbsOutAndReenters( "../../../contexts/tools/domain/formats/cursor-hooks-project-merge.js", - "telemetry", + "acme", 4 ) ).toBe(false); diff --git a/cli/tests/architecture/folder-size.arch.test.ts b/cli/tests/architecture/folder-size.arch.test.ts index f06a6343b..bdad3de0e 100644 --- a/cli/tests/architecture/folder-size.arch.test.ts +++ b/cli/tests/architecture/folder-size.arch.test.ts @@ -13,25 +13,14 @@ const MAX_FILES_PER_FOLDER = 10; * files are shuffled. The test asserts the count, so a reason nobody measured fails here. */ const BASELINE: readonly { readonly path: string; readonly count: number }[] = [ - // Twelve files carry the command surface, plus three helpers no other folder imports. That + // Eleven files carry the command surface, plus three helpers no other folder imports. That // is the flattest mapping from the CLI's surface to its source; moving the helpers out - // would leave twelve, still over the limit and clearer about nothing. - { path: "src/presentation/commands", count: 15 }, + // would leave eleven, still over the limit and clearer about nothing. + { path: "src/presentation/commands", count: 14 }, // One display module per command, mirroring `presentation/commands/`: that mirror is what // lets a command's rendering be asserted without running the binary. Fewer files would mean // one module rendering several commands, which is what put the printing in the actions. - { path: "src/presentation/display", count: 19 }, - // Eleven separate vocabularies with no pair among them, the file helpers already grouped - // under `reading/`. Reaching ten means a folder holding one file: a grouping invented to - // satisfy a count is worse than the count. - { path: "src/kernel", count: 11 }, - // Telemetry's own vocabulary — what a record is, how a report is shaped, whose a figure is - // — is one subject: splitting it by shape files `cost-report.ts` away from the envelope it - // fills. - { path: "src/contexts/telemetry/domain", count: 20 }, - // Measurement reads that many things it does not own: a sink, a journal, an identity, a - // host registry, hook trust and the rest. One port per question; collapsing two answers two. - { path: "src/contexts/telemetry/domain/ports", count: 11 }, + { path: "src/presentation/display", count: 14 }, // `marketplace-source-conflict.ts` is read by both the sync-time guard and `doctor`, and no // grouping here fits it: the marketplace files beside it are each a tool-build concern it is // not, and moving one out to make room is the shuffle this rule refuses. diff --git a/cli/tests/architecture/helpers.ts b/cli/tests/architecture/helpers.ts index 12e3fbde0..aa72516fb 100644 --- a/cli/tests/architecture/helpers.ts +++ b/cli/tests/architecture/helpers.ts @@ -133,9 +133,6 @@ export const ALLOWED = new Set([ "framework->tools", "framework->distribution", "translate->tools", - // Measurement asks a tool what it declares; a tool declares nothing about measurement in - // return, since the vocabulary both speak sits in `kernel/measurement.ts`. - "telemetry->tools", ]); /** diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 48a721161..aa57e6ebf 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -40,7 +40,6 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] path: "src/contexts/framework/application/clean/clean-user-scope-use-case.ts", injected: 12, }, - { path: "src/contexts/telemetry/application/diagnose-telemetry-use-case.ts", injected: 10 }, { path: "src/contexts/framework/application/restore/restore-tool-files-use-case.ts", injected: 9, @@ -55,8 +54,6 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] path: "src/contexts/framework/application/doctor/doctor-registration-use-case.ts", injected: 7, }, - { path: "src/contexts/telemetry/application/read-local-cost-use-case.ts", injected: 7 }, - { path: "src/contexts/telemetry/application/report-cost-use-case.ts", injected: 7 }, { path: "src/contexts/framework/application/install/install-ide-tool-use-case.ts", injected: 6 }, { path: "src/contexts/framework/application/plugin/plugin-install-use-case.ts", injected: 6 }, { path: "src/contexts/framework/application/plugin/plugin-update-use-case.ts", injected: 6 }, @@ -89,7 +86,6 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] path: "src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts", injected: 5, }, - { path: "src/contexts/telemetry/application/telemetry-on-use-case.ts", injected: 5 }, { path: "src/contexts/translate/application/strategies/marketplace-build-strategy.ts", injected: 5, diff --git a/cli/tests/architecture/tool-addition-cost.arch.test.ts b/cli/tests/architecture/tool-addition-cost.arch.test.ts index 1df5051b1..292512f46 100644 --- a/cli/tests/architecture/tool-addition-cost.arch.test.ts +++ b/cli/tests/architecture/tool-addition-cost.arch.test.ts @@ -72,28 +72,15 @@ const BASELINE: readonly { readonly path: string; readonly named: number }[] = [ { path: "src/runtime/wiring/framework.ts", named: 7 }, { path: "src/runtime/wiring/tools.ts", named: 7 }, { path: "src/runtime/wiring/translate.ts", named: 7 }, - // A profile cannot name the adapter reading its transcripts without putting infrastructure - // in the domain, so the tool-to-reader map lives at the composition root instead. - { path: "src/runtime/wiring/telemetry.ts", named: 4 }, // Which file each host keeps its plugin registry in, called from the composition root // alone; the reader classes beside it name no tool. { path: "src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts", named: 3, }, - // An adapter for exactly one tool, naming the binary it shells out to. A tool named in - // its own adapter is not a list a new tool joins — a new tool brings its own adapter. - { path: "src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts", named: 1 }, // Cursor's project hooks file, named after the tool whose file it is: the directory it // writes into is Cursor's own, not a list a sixth tool joins. { path: "src/contexts/tools/domain/formats/cursor-hooks-project-merge.ts", named: 1 }, - // An adapter for exactly one tool, naming its own session-state directory. - { path: "src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts", named: 1 }, - // An adapter for exactly one tool, naming its own hook-trust config path. - { path: "src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts", named: 1 }, - // Real coupling, not excused: one shared file reaches into two tools' own directories to - // detect whether either was ever used, and a third tool would extend it. - { path: "src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts", named: 2 }, ]; describe("a tool identifier stays inside its own profile", () => { diff --git a/cli/tests/contexts/distribution/infrastructure/git-adapter-commit-trailer.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/git-adapter-commit-trailer.integration.test.ts deleted file mode 100644 index 1777d1f8a..000000000 --- a/cli/tests/contexts/distribution/infrastructure/git-adapter-commit-trailer.integration.test.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript, - sessionTrailerHookLine, -} from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; -import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; -import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; -import { GitAdapter } from "../../../../src/runtime/git/git-adapter.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; - -/** - * The real adapter against real repositories: where git looks for a hook is not knowable - * from the shape of `.git` alone — `core.hooksPath` and a linked worktree each move it. - */ -const created: string[] = []; - -function gitEnv(): NodeJS.ProcessEnv { - return Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); -} - -function git(cwd: string, ...args: string[]): string { - return execFileSync("git", args, { cwd, encoding: "utf8", env: gitEnv() }); -} - -function makeRepo(prefix: string): string { - const dir = mkdtempSync(join(tmpdir(), `aidd-trailer-${prefix}-`)); - created.push(dir); - git(dir, "init", "-q", "."); - return dir; -} - -function adapter(): GitAdapter { - return new GitAdapter(new FileAdapter(new HasherAdapter(), new CapturingLogger())); -} - -afterEach(() => { - for (const dir of created) rmSync(dir, { recursive: true, force: true }); - created.length = 0; -}); - -describe("installing the trailer hook where git will actually run it", () => { - it("installs into a plain repository, and says it did", async () => { - const repo = makeRepo("plain"); - - const installed = await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - const hooks = join(repo, ".git", "hooks"); - expect(installed).toEqual({ lineAdded: true }); - expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).toContain( - sessionTrailerHookLine(join(hooks, SESSION_TRAILER_DELEGATE_FILE)) - ); - expect(existsSync(join(hooks, SESSION_TRAILER_DELEGATE_FILE))).toBe(true); - }); - - // `.git/hooks` is not where git looks when a `core.hooksPath` is set, and a hook written - // there runs on nothing while reporting success. - it("installs where core.hooksPath points, never into .git/hooks it would ignore", async () => { - const repo = makeRepo("hookspath"); - const elsewhere = join(repo, "team-hooks"); - mkdirSync(elsewhere, { recursive: true }); - git(repo, "config", "core.hooksPath", elsewhere); - - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - expect(existsSync(join(elsewhere, "prepare-commit-msg"))).toBe(true); - expect(existsSync(join(repo, ".git", "hooks", "prepare-commit-msg"))).toBe(false); - }); - - it("installs into the common repository's hooks from inside a linked worktree", async () => { - const repo = makeRepo("worktree"); - writeFileSync(join(repo, "seed.txt"), "seed\n"); - git(repo, "add", "seed.txt"); - git(repo, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "seed"); - const linked = join(repo, "linked"); - git(repo, "worktree", "add", "-q", linked); - - await adapter().installCommitMessageDelegate( - linked, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - expect(existsSync(join(repo, ".git", "hooks", "prepare-commit-msg"))).toBe(true); - }); - - it("keeps a hook the repository already had, and adds one line to it", async () => { - const repo = makeRepo("existing"); - const hooks = join(repo, ".git", "hooks"); - mkdirSync(hooks, { recursive: true }); - writeFileSync(join(hooks, "prepare-commit-msg"), "#!/bin/sh\necho theirs\n"); - - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - const content = readFileSync(join(hooks, "prepare-commit-msg"), "utf8"); - expect(content).toContain("echo theirs"); - expect(content).toContain(sessionTrailerHookLine(join(hooks, SESSION_TRAILER_DELEGATE_FILE))); - }); - - it("adds its line once however often it is installed", async () => { - const repo = makeRepo("twice"); - - const first = await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - const second = await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - const content = readFileSync(join(repo, ".git", "hooks", "prepare-commit-msg"), "utf8"); - expect(first).toEqual({ lineAdded: true }); - expect(second).toEqual({ lineAdded: false }); - expect(content.split("aidd-session-trailer.sh").length - 1).toBe(1); - }); - - it("reports nothing installed outside a repository, rather than failing", async () => { - const notARepo = mkdtempSync(join(tmpdir(), "aidd-trailer-none-")); - created.push(notARepo); - - await expect( - adapter().installCommitMessageDelegate( - notARepo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ) - ).resolves.toEqual({ lineAdded: false }); - }); - - // A lefthook marker outside a git repository has no `$(git rev-parse --git-common-dir)` - // for a hand-added job to resolve against, so naming a manager there prints a dead job. - it("names no manager outside a repository, even with a lefthook marker at the root", async () => { - const notARepo = mkdtempSync(join(tmpdir(), "aidd-trailer-lefthook-nogit-")); - created.push(notARepo); - writeFileSync(join(notARepo, "lefthook.yml"), "prepare-commit-msg:\n commands: {}\n"); - - await expect( - adapter().installCommitMessageDelegate( - notARepo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ) - ).resolves.toEqual({ lineAdded: false }); - }); -}); - -/** - * Neither `lefthook.yml` nor `.husky/*` is ever written by this CLI, yet the delegate must - * land at `$(git rev-parse --git-common-dir)/hooks`, which husky's `core.hooksPath` misses. - */ -describe("installing where lefthook or husky already owns prepare-commit-msg", () => { - it("reports the manager, writes no line, and touches nothing lefthook owns", async () => { - const repo = makeRepo("lefthook-owned"); - writeFileSync(join(repo, "lefthook.yml"), "prepare-commit-msg:\n commands: {}\n"); - - const result = await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - expect(result).toEqual({ - hookManager: "lefthook", - managerCallsDelegate: false, - lineAdded: false, - }); - expect(existsSync(join(repo, ".git", "hooks", "prepare-commit-msg"))).toBe(false); - expect(readFileSync(join(repo, "lefthook.yml"), "utf8")).toBe( - "prepare-commit-msg:\n commands: {}\n" - ); - expect(existsSync(join(repo, ".git", "hooks", SESSION_TRAILER_DELEGATE_FILE))).toBe(true); - }); - - it("writes the delegate to the common git dir, never to husky's own core.hooksPath", async () => { - const repo = makeRepo("husky-owned"); - mkdirSync(join(repo, ".husky"), { recursive: true }); - writeFileSync(join(repo, ".husky", "prepare-commit-msg"), "#!/bin/sh\necho theirs\n"); - git(repo, "config", "core.hooksPath", ".husky"); - - const result = await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - expect(result).toEqual({ hookManager: "husky", managerCallsDelegate: false, lineAdded: false }); - expect(readFileSync(join(repo, ".husky", "prepare-commit-msg"), "utf8")).toBe( - "#!/bin/sh\necho theirs\n" - ); - expect(existsSync(join(repo, ".husky", SESSION_TRAILER_DELEGATE_FILE))).toBe(false); - expect(existsSync(join(repo, ".git", "hooks", SESSION_TRAILER_DELEGATE_FILE))).toBe(true); - }); -}); - -describe("removing it again", () => { - it("takes back its own line and its own file, and says it did", async () => { - const repo = makeRepo("remove"); - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - const removed = await adapter().removeCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE - ); - - const hooks = join(repo, ".git", "hooks"); - expect(removed).toEqual({ removed: true }); - expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).not.toContain( - SESSION_TRAILER_DELEGATE_FILE - ); - expect(existsSync(join(hooks, SESSION_TRAILER_DELEGATE_FILE))).toBe(false); - }); - - it("leaves every other line of somebody else's hook exactly as it found it", async () => { - const repo = makeRepo("shared"); - const hooks = join(repo, ".git", "hooks"); - mkdirSync(hooks, { recursive: true }); - writeFileSync(join(hooks, "prepare-commit-msg"), "#!/bin/sh\necho theirs\nexit 0\n"); - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - await adapter().removeCommitMessageDelegate(repo, SESSION_TRAILER_DELEGATE_FILE); - - expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).toBe( - "#!/bin/sh\necho theirs\nexit 0\n" - ); - }); - - it("reports nothing to remove when it was never installed", async () => { - const repo = makeRepo("never"); - - await expect( - adapter().removeCommitMessageDelegate(repo, SESSION_TRAILER_DELEGATE_FILE) - ).resolves.toEqual({ removed: false }); - }); - - /** Once a manager owns `prepare-commit-msg`, `on` writes the delegate to - * `$(git rev-parse --git-common-dir)/hooks`, ignoring `core.hooksPath`; `off` must agree. */ - it("removes the delegate on and off agree on, even where husky moves core.hooksPath", async () => { - const repo = makeRepo("husky-remove"); - mkdirSync(join(repo, ".husky"), { recursive: true }); - writeFileSync(join(repo, ".husky", "prepare-commit-msg"), "#!/bin/sh\necho theirs\n"); - git(repo, "config", "core.hooksPath", ".husky"); - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - expect(existsSync(join(repo, ".git", "hooks", SESSION_TRAILER_DELEGATE_FILE))).toBe(true); - - const removed = await adapter().removeCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE - ); - - expect(removed).toEqual({ removed: true, hookManager: "husky", managerCallsDelegate: false }); - expect(existsSync(join(repo, ".git", "hooks", SESSION_TRAILER_DELEGATE_FILE))).toBe(false); - // husky's own hook file is not aidd's to touch, on the way in or on the way out. - expect(readFileSync(join(repo, ".husky", "prepare-commit-msg"), "utf8")).toBe( - "#!/bin/sh\necho theirs\n" - ); - }); -}); - -/** - * A resumed Codex rollout carries a `session_meta.id` — what becomes a record's `vendor_id` - * — different from its own `session_meta.session_id`: thread and rollout are two things. - */ -describe("the reason the Codex half of this join is only a candidate", () => { - it("still holds a resumed rollout whose own id differs from the session it continues", () => { - const rollout = fileURLToPath( - new URL( - "../../../fixtures/local-cost/.codex/sessions/2026/07/29/" + - "rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl", - import.meta.url - ) - ); - const first = readFileSync(rollout, "utf8").split("\n")[0] ?? ""; - const meta = JSON.parse(first) as { - payload?: { id?: string; session_id?: string }; - }; - - expect(meta.payload?.id).toBe("019fae6f-2009-7cd3-86b2-b8f83481b160"); - expect(meta.payload?.session_id).toBeDefined(); - expect(meta.payload?.session_id).not.toBe(meta.payload?.id); - }); -}); diff --git a/cli/tests/contexts/distribution/infrastructure/git-commit-trailer-setup.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/git-commit-trailer-setup.integration.test.ts deleted file mode 100644 index f460cae32..000000000 --- a/cli/tests/contexts/distribution/infrastructure/git-commit-trailer-setup.integration.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerHookLine, -} from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; -import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; -import { GitAdapter } from "../../../../src/runtime/git/git-adapter.js"; -import { environmentWithoutGitVariables } from "../../../../src/runtime/git/git-environment.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; - -/** Against a real git rather than a fake one: `%(trailers:key=…)` is git's own reader, and - * asserting against a regex of ours would prove only that the regex agrees with itself. */ -let dir: string; -let git: GitAdapter; - -/** Windows records no execute bit: every readable file reports `0o666`, so "present but - * unrunnable" is a state that exists only where the bit does. */ -const REMOVING_THE_BIT_MEANS_SOMETHING = process.platform !== "win32"; - -/** Git exports `GIT_DIR` and friends into everything it spawns, this suite included when it - * runs from a commit hook. Stripped, or these read the repository being committed. */ -function run(args: readonly string[], env: NodeJS.ProcessEnv = {}): void { - execFileSync("git", [...args], { - cwd: dir, - env: { ...environmentWithoutGitVariables(process.env), ...env }, - }); -} - -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "aidd-trailer-setup-")); - execFileSync("git", ["init", "-q", dir], { env: environmentWithoutGitVariables(process.env) }); - run(["config", "user.email", "t@example.com"]); - run(["config", "user.name", "T"]); - git = new GitAdapter(new FileAdapter(new DeterministicHasher(), new CapturingLogger())); -}); - -afterEach(async () => { - await rm(dir, { recursive: true, force: true }); -}); - -function read() { - return git.readCommitTrailerSetup(dir, SESSION_TRAILER_DELEGATE_FILE, SESSION_TRAILER_TOKEN, 20); -} - -async function hooksDir(): Promise { - const at = join(dir, ".git", "hooks"); - await mkdir(at, { recursive: true }); - return at; -} - -async function installDelegate(mode = 0o755): Promise { - const at = join(await hooksDir(), SESSION_TRAILER_DELEGATE_FILE); - await writeFile(at, "#!/bin/sh\nexit 0\n"); - await chmod(at, mode); - return at; -} - -function commit(message: string): void { - run(["commit", "-q", "--allow-empty", "-m", message]); -} - -function commitCarrying(message: string, session: string): void { - run([ - "commit", - "-q", - "--allow-empty", - "-m", - `${message}\n\n${SESSION_TRAILER_TOKEN}: ${session}`, - ]); -} - -describe("what check reads about the commit trailer", () => { - it("names the directory git runs hooks from, not one assumed", async () => { - expect((await read()).hooksDir).toContain(join(".git", "hooks")); - }); - - it("tells a delegate that is not executable from one that is absent", async () => { - expect((await read()).delegate).toBe("absent"); - - if (REMOVING_THE_BIT_MEANS_SOMETHING) { - await installDelegate(0o644); - expect((await read()).delegate).toBe("not-executable"); - } - - await installDelegate(0o755); - expect((await read()).delegate).toBe("executable"); - }); - - it("says whether prepare-commit-msg calls the delegate", async () => { - const delegatePath = await installDelegate(); - expect((await read()).callSite).toBe("no-hook-file"); - - const hookPath = join(await hooksDir(), "prepare-commit-msg"); - await writeFile(hookPath, "#!/bin/sh\n# generated\nexit 0\n"); - expect((await read()).callSite).toBe("missing"); - - await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); - expect((await read()).callSite).toBe("present"); - }); - - it("says the hook is somebody else's, and does not say whose", async () => { - const delegatePath = await installDelegate(); - const hookPath = join(await hooksDir(), "prepare-commit-msg"); - - await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); - expect((await read()).hookHasOtherContent).toBe(false); - - await writeFile( - hookPath, - `#!/bin/sh\nlefthook run x\n${sessionTrailerHookLine(delegatePath)}\n` - ); - expect((await read()).hookHasOtherContent).toBe(true); - }); - - /** A count, not a boolean: "some of your commits carry it" is not something a person can - * check, while "0 of the last 3" is the entire finding. */ - it("counts how many recent commits actually carry it", async () => { - commit("one"); - commitCarrying("two", "s-1"); - commitCarrying("three", "s-2"); - - expect((await read()).recentlyCarrying).toEqual({ carrying: 2, examined: 3 }); - }); - - /** A merge carries no trailer by the delegate's own design, so counting one puts a commit - * in the denominator that can never be in the numerator. */ - it("does not count merges, which can never carry it", async () => { - commitCarrying("one", "s-1"); - run(["checkout", "-q", "-b", "side"]); - commitCarrying("side", "s-2"); - run(["checkout", "-q", "-"]); - commitCarrying("main", "s-3"); - run(["merge", "-q", "--no-ff", "-m", "merge", "side"]); - - const counted = (await read()).recentlyCarrying; - - expect(counted).toEqual({ carrying: 3, examined: 3 }); - }); - - // Never `0`: a repository with no commits and one whose commits are all unstamped are - // different facts, and only the second is something to act on. - it("reports no history rather than zero when there are no commits", async () => { - expect((await read()).recentlyCarrying).toBeUndefined(); - }); - - it("says there is no repository, rather than that git could not answer", async () => { - const outside = await mkdtemp(join(tmpdir(), "aidd-trailer-nogit-")); - try { - const setup = await git.readCommitTrailerSetup( - outside, - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - 20 - ); - - expect(setup.hooksDirMissing).toBe("no-repository"); - expect(setup.recentlyCarrying).toBeUndefined(); - expect(setup.delegate).toBe("absent"); - } finally { - await rm(outside, { recursive: true, force: true }); - } - }); - - // Git refuses to run a hook without the bit and prints a hint on every commit, so an - // install that looks perfect can write nothing. The hook's own mode, not the delegate's. - it("says whether the hook itself is executable", async () => { - const delegatePath = await installDelegate(); - const hookPath = join(await hooksDir(), "prepare-commit-msg"); - await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); - - if (REMOVING_THE_BIT_MEANS_SOMETHING) { - await chmod(hookPath, 0o644); - expect((await read()).hookExecutable).toBe(false); - } - - await chmod(hookPath, 0o755); - expect((await read()).hookExecutable).toBe(true); - }); - - it("has no opinion on a hook's mode when there is no hook", async () => { - await installDelegate(); - - expect((await read()).hookExecutable).toBeUndefined(); - }); -}); - -/** A manager regenerates `prepare-commit-msg` from its own config on every install, so - * anything read from the hook itself is already stale; the root marker file survives that. */ -describe("which manager owns prepare-commit-msg, read from the repository root", () => { - it("names lefthook from lefthook.yml alone, before lefthook has ever generated a hook", async () => { - await writeFile(join(dir, "lefthook.yml"), "prepare-commit-msg:\n commands: {}\n"); - - const setup = await read(); - - expect(setup.hookManager).toBe("lefthook"); - expect(setup.callSite).toBe("no-hook-file"); - }); - - it("names lefthook even once a hook file exists, never reading that file to decide", async () => { - await writeFile(join(dir, "lefthook.yml"), "prepare-commit-msg:\n commands: {}\n"); - const hookPath = join(await hooksDir(), "prepare-commit-msg"); - // What lefthook itself regenerates: no aidd trailer line and no mention of lefthook, so a - // reader deciding from the hook's own contents would have nothing here to key off. - await writeFile(hookPath, "#!/bin/sh\nexit 0\n"); - - expect((await read()).hookManager).toBe("lefthook"); - }); - - it("names husky from a .husky directory at the root", async () => { - await mkdir(join(dir, ".husky"), { recursive: true }); - - expect((await read()).hookManager).toBe("husky"); - }); - - it("names neither manager when no marker sits at the root", async () => { - expect((await read()).hookManager).toBeUndefined(); - }); -}); - -/** This repository's own lefthook job, inlined rather than read off disk: it calls the - * delegate through the dynamic form, never the absolute-path `sessionTrailerHookLine` one. */ -const REAL_LEFTHOOK_PREPARE_COMMIT_MSG_JOB = `prepare-commit-msg: - commands: - aidd-session-trailer: - run: | - delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh" - if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi -`; - -describe("whether the manager's own config already calls the delegate", () => { - it("reports not wired when lefthook.yml exists but names no job for it", async () => { - await writeFile(join(dir, "lefthook.yml"), "commit-msg:\n commands: {}\n"); - - expect((await read()).managerCallsDelegate).toBe(false); - }); - - it("reports wired against this repository's own lefthook.yml job", async () => { - await writeFile(join(dir, "lefthook.yml"), REAL_LEFTHOOK_PREPARE_COMMIT_MSG_JOB); - - const setup = await read(); - - expect(setup.hookManager).toBe("lefthook"); - expect(setup.managerCallsDelegate).toBe(true); - }); - - it("reports wired from .husky/prepare-commit-msg the same way", async () => { - await mkdir(join(dir, ".husky"), { recursive: true }); - await writeFile( - join(dir, ".husky", "prepare-commit-msg"), - 'delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh"\n' + - '[ -f "$delegate" ] && sh "$delegate" "$@"\n' - ); - - expect((await read()).managerCallsDelegate).toBe(true); - }); -}); - -/** Husky moves `core.hooksPath` under `.husky/` while `on` writes the delegate to the common - * git dir, so following `core.hooksPath` reads it "absent" however often `on` ran. */ -describe("reading the delegate's own state under a manager that moves core.hooksPath", () => { - it("finds the delegate in the common git dir, not wherever core.hooksPath points", async () => { - await mkdir(join(dir, ".husky"), { recursive: true }); - run(["config", "core.hooksPath", ".husky"]); - const commonHooks = join(dir, ".git", "hooks"); - await mkdir(commonHooks, { recursive: true }); - const delegatePath = join(commonHooks, SESSION_TRAILER_DELEGATE_FILE); - await writeFile(delegatePath, "#!/bin/sh\nexit 0\n"); - await chmod(delegatePath, 0o755); - - const setup = await read(); - - expect(setup.hookManager).toBe("husky"); - expect(setup.delegate).toBe("executable"); - }); - - it("reports the delegate not-executable rather than absent, under the same divergence", async () => { - if (!REMOVING_THE_BIT_MEANS_SOMETHING) return; - await mkdir(join(dir, ".husky"), { recursive: true }); - run(["config", "core.hooksPath", ".husky"]); - const commonHooks = join(dir, ".git", "hooks"); - await mkdir(commonHooks, { recursive: true }); - const delegatePath = join(commonHooks, SESSION_TRAILER_DELEGATE_FILE); - await writeFile(delegatePath, "#!/bin/sh\nexit 0\n"); - await chmod(delegatePath, 0o644); - - expect((await read()).delegate).toBe("not-executable"); - }); -}); diff --git a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts index 0aeb7546e..5ea1579b1 100644 --- a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts @@ -137,12 +137,12 @@ describe("clean", () => { expect(content).toContain("dist/"); }); - it("with force removes aidd_docs/runs/ entry from .gitignore, same as the pipeline adds", async () => { + it("with force removes the .aidd/cache/ entry from .gitignore, same as the pipeline adds", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); const gitignorePath = join(PROJECT_ROOT, ".gitignore"); - await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\naidd_docs/runs/\ndist/\n"); + await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\ndist/\n"); const useCase = new CleanWithKnownHostSource( deps.fs, @@ -154,7 +154,6 @@ describe("clean", () => { const content = deps.fs.getFile(gitignorePath); expect(content).not.toContain(".aidd/cache/"); - expect(content).not.toContain("aidd_docs/runs/"); expect(content).toContain("node_modules/"); expect(content).toContain("dist/"); }); diff --git a/cli/tests/contexts/framework/application/helpers.ts b/cli/tests/contexts/framework/application/helpers.ts index 97fb4ff6b..dcca5fc02 100644 --- a/cli/tests/contexts/framework/application/helpers.ts +++ b/cli/tests/contexts/framework/application/helpers.ts @@ -17,7 +17,6 @@ import { PostInstallPipelineUseCase } from "../../../../src/contexts/framework/a import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { ManifestRepositoryAdapter } from "../../../../src/contexts/framework/infrastructure/manifest-repository-adapter.js"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; -import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; import { isIdeToolId } from "../../../../src/contexts/tools/domain/registry.js"; import type { Prompter } from "../../../../src/kernel/ports/prompter.js"; import type { VersionReader } from "../../../../src/kernel/ports/version-reader.js"; @@ -31,19 +30,6 @@ import { SilentPrompterAdapter } from "../../../../src/runtime/prompter/prompter import { CurrentVersionAdapter } from "../../../../src/runtime/self-update/current-version-adapter.js"; export const linuxPlatform: Platform = { current: () => "linux" }; export const win32Platform: Platform = { current: () => "win32" }; -export const noGit: VersionControl = { - installCommitMessageDelegate: async () => ({ lineAdded: false }), - removeCommitMessageDelegate: async () => ({ removed: false }), - listTrackedFiles: async () => [], - isRepository: async () => false, - hasHistoryFor: async () => false, - readCommitTrailerSetup: async () => ({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - }), -}; - export const OverwritePrompter = SilentPrompterAdapter; export class KeepPrompter implements Prompter { diff --git a/cli/tests/contexts/framework/application/install/post-install-pipeline-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/post-install-pipeline-use-case.unit.test.ts index 3d95eeb3f..522b5e0e6 100644 --- a/cli/tests/contexts/framework/application/install/post-install-pipeline-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/post-install-pipeline-use-case.unit.test.ts @@ -53,11 +53,11 @@ describe("post-install pipeline", () => { expect(recordingGitignore.calls).toHaveLength(1); expect(recordingGitignore.calls[0]).toEqual( - expect.arrayContaining([".aidd/cache/", "aidd_docs/runs/", ".claude/settings.local.json"]) + expect.arrayContaining([".aidd/cache/", ".claude/settings.local.json"]) ); }); - it("ignores the run journal, and nothing wider", async () => { + it("ignores the cache, and nothing wider than aidd_docs", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude"); const manifest = await deps.manifestRepo.load(); @@ -69,7 +69,7 @@ describe("post-install pipeline", () => { }); const gitignoreContent = deps.fs.getFile(join(PROJECT_ROOT, ".gitignore")) ?? ""; - expect(gitignoreContent).toContain("aidd_docs/runs/"); + expect(gitignoreContent).toContain(".aidd/cache/"); expect(gitignoreContent).not.toContain("aidd_docs/*"); expect(gitignoreContent).not.toMatch(/^aidd_docs\/$/mu); }); diff --git a/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts index 616e8f2a8..be1e06ba5 100644 --- a/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts @@ -25,7 +25,6 @@ const noopFs: FileReader & FileWriter = { createDirectory: async () => {}, deleteEmptyDirectories: async () => {}, deleteDirectory: async () => {}, - chmodExecutable: async () => {}, }; const noopHasher: Hasher = { diff --git a/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts b/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts index a49f34034..764502e9d 100644 --- a/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts @@ -25,18 +25,10 @@ import { AI_TOOL_IDS } from "../../../../src/kernel/tool.js"; * An artefact is carried byte for byte: a path rewritten inside a program no longer parses. */ function pluginFile(relativePath: string): string { - return readFileSync(join(REPOSITORY_ROOT, "plugins", "aidd-telemetry", relativePath), "utf8"); + return readFileSync(join(REPOSITORY_ROOT, "plugins", "aidd-context", relativePath), "utf8"); } -const ARTEFACTS = [ - "hooks/journal.cjs", - "hooks/journal.cjs", - "hooks/lib/record.cjs", - "hooks/lib/repo.cjs", - "hooks/lib/file-writes.cjs", - "hooks/lib/step-starts.cjs", - "hooks/lib/host.cjs", -] as const; +const ARTEFACTS = ["hooks/update_memory.js"] as const; describe("a plugin's executable files survive being installed", () => { for (const relativePath of ARTEFACTS) { @@ -58,21 +50,24 @@ describe("a plugin's executable files survive being installed", () => { /** The decisive check: not "would a rewrite damage it", but "does installing the plugin * actually put it there, unchanged". The path is the fixture; the content is real bytes. */ describe("installing the plugin carries a skill's own script, on every tool", () => { - const SCRIPT = "skills/02-check/scripts/example.cjs"; - const SCRIPT_CONTENT = pluginFile("hooks/journal.cjs"); + const SCRIPT = "skills/02-project-memory/scripts/example.cjs"; + const SCRIPT_CONTENT = pluginFile("hooks/update_memory.js"); const translator = new PluginContentTranslator({ hash: () => new FileHash("a".repeat(32)) }); function distributionOf(): PluginDistribution { const skills = [ - { relativePath: "skills/02-check/SKILL.md", content: pluginFile("skills/02-check/SKILL.md") }, + { + relativePath: "skills/02-project-memory/SKILL.md", + content: pluginFile("skills/02-project-memory/SKILL.md"), + }, { relativePath: SCRIPT, content: SCRIPT_CONTENT }, ]; const hooks = [ { relativePath: "hooks/hooks.json", content: pluginFile("hooks/hooks.json") }, - { relativePath: "hooks/journal.cjs", content: pluginFile("hooks/journal.cjs") }, + { relativePath: "hooks/update_memory.js", content: pluginFile("hooks/update_memory.js") }, ]; return new PluginDistribution({ - manifest: { name: "aidd-telemetry", version: "0.1.0" }, + manifest: { name: "aidd-context", version: "0.1.0" }, format: "claude", files: [...skills, ...hooks], components: { skills, commands: [], agents: [], rules: [], hooks, mcp: [] }, @@ -83,7 +78,7 @@ describe("installing the plugin carries a skill's own script, on every tool", () it(`${tool.toolId} installs it byte for byte`, () => { const installed = translator .translate(distributionOf(), tool) - .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); + .find((file) => file.relativePath.endsWith("02-project-memory/scripts/example.cjs")); expect(installed, `${tool.toolId} drops the script entirely`).toBeDefined(); expect(installed?.content).toBe(SCRIPT_CONTENT); @@ -93,12 +88,12 @@ describe("installing the plugin carries a skill's own script, on every tool", () it("still translates the prose beside it", () => { const installed = translator .translate(distributionOf(), claude) - .find((file) => file.relativePath.endsWith("02-check/SKILL.md")); + .find((file) => file.relativePath.endsWith("02-project-memory/SKILL.md")); // Carrying artefacts verbatim must not turn every skill into an artefact: this one // still goes through the frontmatter conversion, so it is not byte-identical. - expect(installed?.content).not.toBe(pluginFile("skills/02-check/SKILL.md")); - expect(installed?.content).toContain("States what is in place"); + expect(installed?.content).not.toBe(pluginFile("skills/02-project-memory/SKILL.md")); + expect(installed?.content).toContain("memory of its architecture"); }); /** A script whose text that tool's own rewrite really does change. Each tool rewrites its @@ -109,11 +104,14 @@ describe("installing the plugin carries a skill's own script, on every tool", () function distributionWithScript(content: string): PluginDistribution { const skills = [ - { relativePath: "skills/02-check/SKILL.md", content: pluginFile("skills/02-check/SKILL.md") }, + { + relativePath: "skills/02-project-memory/SKILL.md", + content: pluginFile("skills/02-project-memory/SKILL.md"), + }, { relativePath: SCRIPT, content }, ]; return new PluginDistribution({ - manifest: { name: "aidd-telemetry", version: "0.1.0" }, + manifest: { name: "aidd-context", version: "0.1.0" }, format: "claude", files: skills, components: { skills, commands: [], agents: [], rules: [], hooks: [], mcp: [] }, @@ -128,7 +126,7 @@ describe("installing the plugin carries a skill's own script, on every tool", () const installed = translator .translate(distributionWithScript(script), tool) - .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); + .find((file) => file.relativePath.endsWith("02-project-memory/scripts/example.cjs")); expect(installed?.content).toBe(script); }); @@ -139,7 +137,7 @@ describe("installing the plugin carries a skill's own script, on every tool", () // on the way. The script survives there only because that rewrite leaves it alone. const installed = translator .translate(distributionOf(), opencode) - .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); + .find((file) => file.relativePath.endsWith("02-project-memory/scripts/example.cjs")); expect(installed, "opencode drops the script entirely").toBeDefined(); expect(installed?.content).toBe(SCRIPT_CONTENT); diff --git a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts deleted file mode 100644 index 0b19c9176..000000000 --- a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts +++ /dev/null @@ -1,1020 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; -import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; -import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; -import { DiagnoseTelemetryUseCase } from "../../../../src/contexts/telemetry/application/diagnose-telemetry-use-case.js"; -import type { HookTrustReader } from "../../../../src/contexts/telemetry/domain/ports/hook-trust-reader.js"; -import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import type { - LocalCostCandidateRecord, - LocalCostReadResult, - SessionCostReader, -} from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; -import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; -import type { TelemetryCodexHookTrust } from "../../../../src/contexts/telemetry/domain/telemetry-claim.js"; -import type { HostPluginRegistryReader } from "../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; -import type { AiToolId } from "../../../../src/kernel/tool.js"; -import { installedPluginsFromManifest } from "../../../../src/runtime/wiring/installed-plugins-from-manifest.js"; -import { FakeCurrentVersion } from "../../../helpers/ports/fake-current-version.js"; -import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; -import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; -import { InMemoryRunJournalReader } from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader as StubEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -class StubHookTrustReader implements HookTrustReader { - trust: TelemetryCodexHookTrust = { - readable: true, - trusted: true, - configPath: "/home/.codex/config.toml", - }; - - reads = 0; - - async read(): Promise { - this.reads += 1; - return this.trust; - } -} - -function versionControl(isRepository: boolean): VersionControl { - return { - installCommitMessageDelegate: async () => ({ lineAdded: false }), - removeCommitMessageDelegate: async () => ({ removed: false }), - listTrackedFiles: async () => [], - isRepository: async () => isRepository, - readCommitTrailerSetup: async () => ({ - delegate: "absent" as const, - callSite: "no-hook-file" as const, - hookHasOtherContent: false, - }), - hasHistoryFor: async () => false, - }; -} - -class StubSessionCostReader implements SessionCostReader { - constructor( - private readonly result: LocalCostReadResult = { records: [], sessionFound: false }, - private readonly failure?: string - ) {} - - async read(): Promise { - if (this.failure !== undefined) throw new Error(this.failure); - return this.result; - } -} - -function sessionStart(vendorId: string, at = "2026-08-20T09:00:00Z"): RunJournal["session"] { - return { - type: "session_start", - at, - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: vendorId, - }; -} - -function candidate(overrides: Partial = {}): LocalCostCandidateRecord { - return { - kind: "request", - vendor_id: "s-1", - vendor_field: "session_id", - event_timestamp: "2026-08-20T09:01:00Z", - ...overrides, - }; -} - -function buildUseCase(options: { - evidence?: StubEvidenceReader; - isRepository?: boolean; - journals?: readonly RunJournal[]; - readers?: ReadonlyMap; - hookTrustReader?: StubHookTrustReader; - manifestRepo?: ManifestRepository; - hostRegistries?: ReadonlyMap; - sink?: InMemoryTelemetrySink; -}) { - const evidence = options.evidence ?? new StubEvidenceReader(); - const journalReader = new InMemoryRunJournalReader(); - for (const [index, journal] of (options.journals ?? []).entries()) { - journalReader.set(journal.session?.vendor_id ?? `no-session-${index}`, journal); - } - const hookTrustReader = options.hookTrustReader ?? new StubHookTrustReader(); - const sink = options.sink ?? new InMemoryTelemetrySink(); - const useCase = new DiagnoseTelemetryUseCase( - evidence, - versionControl(options.isRepository ?? true), - journalReader, - options.readers ?? new Map(), - hookTrustReader, - new InMemoryPersonIdentityStore(), - sink, - new FakeCurrentVersion("9.9.9-check"), - installedPluginsFromManifest( - options.manifestRepo ?? { - path: "/test-project/.aidd/manifest.json", - load: async () => null, - save: async () => {}, - delete: async () => {}, - } - ), - options.hostRegistries ?? new Map() - ); - return { useCase, evidence, hookTrustReader, journalReader, sink }; -} - -function runOptions(env: NodeJS.ProcessEnv = {}) { - return { projectRoot: "/repo", homeDir: "/home/dev", env }; -} - -describe("DiagnoseTelemetryUseCase — gating", () => { - it("stops at the switch before judging anything else", async () => { - const evidence = new StubEvidenceReader(); - evidence.enabled = false; - const { useCase } = buildUseCase({ evidence }); - - const result = await useCase.execute(runOptions()); - - expect(result.gate).toMatch(/measurement is off/u); - expect("claims" in result).toBe(false); - }); - - it("names a non-repository, never blaming the hook, once the switch is on", async () => { - const { useCase } = buildUseCase({ isRepository: false }); - - const result = await useCase.execute(runOptions()); - - expect(result.gate).toMatch(/not a git repository/u); - }); -}); - -// A stale export in a tool's own settings file exports whether or not this project's switch is -// on, so it is gathered and reported on both sides of the gate, never folded into a claim. -describe("DiagnoseTelemetryUseCase — a leftover export config", () => { - const LEFTOVER = [ - { path: "/repo/.claude/settings.local.json", keys: ["CLAUDE_CODE_ENABLE_TELEMETRY"] }, - ]; - - it("is reported even when the switch is off and the run is gated", async () => { - const evidence = new StubEvidenceReader(); - evidence.enabled = false; - evidence.leftoverExport = LEFTOVER; - const { useCase } = buildUseCase({ evidence }); - - const result = await useCase.execute(runOptions()); - - expect(result.gate).toMatch(/measurement is off/u); - expect(result.leftoverExportConfig).toEqual(LEFTOVER); - }); - - it("is reported alongside the four claims when the switch is on", async () => { - const evidence = new StubEvidenceReader(); - evidence.leftoverExport = LEFTOVER; - const { useCase } = buildUseCase({ evidence }); - - const result = await useCase.execute(runOptions()); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(result.leftoverExportConfig).toEqual(LEFTOVER); - // Never folded into the health count: "no claim mentions exporting" is a hard rule - // (telemetry-claim.unit.test.ts) this must not get near. - expect(result.claims.some((claim) => claim.claim.toString().includes("export"))).toBe(false); - }); - - it("reports an empty list on a clean machine, never omitting the field", async () => { - const { useCase } = buildUseCase({}); - - const result = await useCase.execute(runOptions()); - - expect(result.leftoverExportConfig).toEqual([]); - }); -}); - -describe("DiagnoseTelemetryUseCase — gathering local evidence", () => { - it("reads the run journal once per check, never once for setup and again for evidence", async () => { - const journal: RunJournal = { - session: sessionStart("s-1"), - boundaries: [ - { type: "step_start", at: "2026-08-20T09:00:30Z", skill: "aidd-dev:02-implement" }, - ], - filesWritten: [], - taskDeclarations: [], - }; - const { useCase, journalReader } = buildUseCase({ journals: [journal] }); - - const result = await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(journalReader.listCalls).toBe(1); - }); - - it("reads every covered tool's own files for every journalled session", async () => { - const journal: RunJournal = { - session: sessionStart("s-1"), - // The pause is what caps the step interval, and is why the candidate below falls inside - // one at all: a journal whose only line is the opener witnesses no later moment. - boundaries: [ - { type: "step_start", at: "2026-08-20T09:00:30Z", skill: "aidd-dev:02-implement" }, - { type: "turn_end", at: "2026-08-20T09:30:00Z" }, - ], - filesWritten: [], - taskDeclarations: [], - }; - const claudeReader = new StubSessionCostReader({ records: [candidate()], sessionFound: true }); - const { useCase } = buildUseCase({ - journals: [journal], - readers: new Map([["claude", claudeReader]]), - }); - - const result = await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(result.claims.find((c) => c.claim === "tool-files-readable")?.verdict).toBe("ok"); - expect(result.claims.find((c) => c.claim === "records-join")?.verdict).toBe("ok"); - }); - - // Copilot fires no stop event, so a Copilot session that opened a skill and then wrote no file - // leaves a journal whose only line is the opener, and the step it opened covers nothing. - it("says records join nothing when the journal's only line is the step that opened", async () => { - const journal: RunJournal = { - session: sessionStart("s-1"), - boundaries: [ - { type: "step_start", at: "2026-08-20T09:00:30Z", skill: "aidd-dev:02-implement" }, - ], - filesWritten: [], - taskDeclarations: [], - }; - const claudeReader = new StubSessionCostReader({ records: [candidate()], sessionFound: true }); - const { useCase } = buildUseCase({ - journals: [journal], - readers: new Map([["claude", claudeReader]]), - }); - - const result = await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - const join = result.claims.find((claim) => claim.claim === "records-join"); - expect(join?.verdict).toBe("fail"); - expect(join?.reason).toBe("all-unattributed"); - }); - - it("names a reader that threw as failing to read, never crashing the whole diagnostic", async () => { - const journal: RunJournal = { - session: sessionStart("s-1"), - boundaries: [], - filesWritten: [], - taskDeclarations: [], - }; - const brokenReader = new StubSessionCostReader(undefined, "ENOENT: no such file"); - const { useCase } = buildUseCase({ - journals: [journal], - readers: new Map([["claude", brokenReader]]), - }); - - const result = await useCase.execute(runOptions()); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(result.claims.find((c) => c.claim === "tool-files-readable")?.detail).toContain( - "ENOENT" - ); - }); - - it("only consults Codex's own hook trust for a Codex-anchored session", async () => { - const hookTrustReader = new StubHookTrustReader(); - hookTrustReader.trust = { - readable: true, - trusted: false, - configPath: "/home/.codex/config.toml", - }; - const { useCase } = buildUseCase({ hookTrustReader }); - - const claudeAnchored = await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })); - if (claudeAnchored.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(claudeAnchored.claims.find((c) => c.claim === "hook-fired")?.reason).toBe( - "recorder-declared-nowhere" - ); - - const codexAnchored = await useCase.execute(runOptions({ CODEX_THREAD_ID: "codex-1" })); - if (codexAnchored.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(codexAnchored.claims.find((c) => c.claim === "hook-fired")?.reason).toBe( - "untrusted-codex-hook" - ); - }); - - it("names every uncovered tool with its own reason", async () => { - const { useCase } = buildUseCase({}); - - const result = await useCase.execute(runOptions()); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(result.uncovered.length).toBeGreaterThan(0); - for (const uncovered of result.uncovered) expect(uncovered.reason.length).toBeGreaterThan(0); - }); -}); - -describe("DiagnoseTelemetryUseCase — every claim is judged", () => { - it("never lets absent evidence produce an ok: no claim is ever left unjudged", async () => { - const { useCase } = buildUseCase({}); - - const result = await useCase.execute(runOptions()); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - for (const claim of result.claims) expect(["ok", "fail", "unknown"]).toContain(claim.verdict); - }); -}); - -// The first claim judges by the same recorder declaration `gatherSetup` reads, never by the -// absence of a run file, which looks identical whether or not the recorder was declared. -describe("DiagnoseTelemetryUseCase — the first claim reads the same declaration setup prints", () => { - it("reports nothing to evaluate when the setup's own recorder declaration is true", async () => { - const evidence = new StubEvidenceReader(); - evidence.recorderDeclaration = { - declared: true, - declaredAt: ["/repo/.claude/settings.json"], - locationsChecked: ["/repo/.aidd/manifest.json", "/repo/.claude/settings.json"], - unreadable: [], - }; - const { useCase } = buildUseCase({ evidence }); - - const result = await useCase.execute(runOptions()); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(result.setup.recorderDeclaration.declared).toBe(true); - const hookFired = result.claims.find((c) => c.claim === "hook-fired"); - expect(hookFired?.verdict).toBe("unknown"); - expect(hookFired?.reason).toBe("recorder-declared-not-yet-fired"); - }); - - it("fails, naming the recorder, when the setup's own recorder declaration is false", async () => { - const evidence = new StubEvidenceReader(); - evidence.recorderDeclaration = { - declared: false, - declaredAt: [], - locationsChecked: ["/repo/.aidd/manifest.json"], - unreadable: [], - }; - const { useCase } = buildUseCase({ evidence }); - - const result = await useCase.execute(runOptions()); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - expect(result.setup.recorderDeclaration.declared).toBe(false); - const hookFired = result.claims.find((c) => c.claim === "hook-fired"); - expect(hookFired?.verdict).toBe("fail"); - expect(hookFired?.detail).toMatch(/recorder is declared nowhere/u); - }); - - it("reads unknown, never a failure, when the setup's own recorder declaration could not be read", async () => { - const evidence = new StubEvidenceReader(); - evidence.recorderDeclaration = { - declared: false, - declaredAt: [], - locationsChecked: ["/repo/.aidd/manifest.json", "/repo/.claude/settings.json"], - unreadable: ["/repo/.claude/settings.json"], - }; - const { useCase } = buildUseCase({ evidence }); - - const result = await useCase.execute(runOptions()); - - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - const hookFired = result.claims.find((c) => c.claim === "hook-fired"); - expect(hookFired?.verdict).toBe("unknown"); - expect(hookFired?.reason).toBe("recorder-declaration-unreadable"); - expect(hookFired?.detail).not.toContain("FAIL"); - }); -}); - -describe("the versions check reports", () => { - function sessionAt(at: string, pluginVersion?: string): RunJournal { - return { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at, - run_id: `run-${at}`, - tool: "claude-code", - vendor_id: `vendor-${at}`, - ...(pluginVersion === undefined ? {} : { plugin_version: pluginVersion }), - }, - }; - } - - it("names this CLI's own version, which a person always has since nothing reads without it", async () => { - const { useCase } = buildUseCase({}); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.versions.cli).toBe("9.9.9-check"); - }); - - it("reports the plugin version the hook itself stamped, never one re-derived here", async () => { - const { useCase } = buildUseCase({ journals: [sessionAt("2026-09-01T10:00:00Z", "0.1.0")] }); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.versions.plugin).toEqual({ kind: "recorded", version: "0.1.0" }); - }); - - it("reports the newest, so an upgrade mid-period is not hidden by the sessions before it", async () => { - const { useCase } = buildUseCase({ - journals: [ - sessionAt("2026-09-01T10:00:00Z", "0.1.0"), - sessionAt("2026-09-02T10:00:00Z", "0.2.0"), - ], - }); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.versions.plugin).toEqual({ kind: "recorded", version: "0.2.0" }); - }); - - it("skips a session carrying no version rather than letting it hide a later one that does", async () => { - // A line written before the field existed must not read as "this install is damaged". - const { useCase } = buildUseCase({ - journals: [sessionAt("2026-09-03T10:00:00Z"), sessionAt("2026-09-01T10:00:00Z", "0.1.0")], - }); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.versions.plugin).toEqual({ kind: "recorded", version: "0.1.0" }); - }); - - it("tells a project that measured nothing yet apart from one whose hook could not name itself", async () => { - // The two silences differ: nothing journalled says nothing about the plugin, while a - // journalled session carrying no version is a plugin that arrived by neither install route. - const nothing = await buildUseCase({}).useCase.execute(runOptions()); - const journalledWithout = await buildUseCase({ - journals: [sessionAt("2026-09-01T10:00:00Z")], - }).useCase.execute(runOptions()); - - expect(nothing.setup.versions.plugin).toEqual({ kind: "nothing-journalled" }); - expect(journalledWithout.setup.versions.plugin).toEqual({ kind: "unrecorded" }); - }); -}); - -/** A hand-edited `.aidd/manifest.json` makes `load` throw: `Manifest`'s parser maps over fields - * it does not guard. A real implementation of the port rather than a cast, so it cannot drift. */ -class ThrowingManifestRepository implements ManifestRepository { - readonly path = "/test-project/.aidd/manifest.json"; - constructor(private readonly failure: Error) {} - async load(): Promise { - throw this.failure; - } - async save(): Promise {} - async delete(): Promise {} -} - -function manifestWithClaudePlugin(marketplace?: string): InMemoryManifestRepository { - const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); - manifest.addPlugin( - "claude", - InstalledPlugin.fromMetadata( - "aidd-telemetry", - "1.0.0", - { kind: "github", repo: "ai-driven-dev/framework" }, - true, - "project", - marketplace - ) - ); - return new InMemoryManifestRepository(manifest); -} - -function registryCarrying( - refs: readonly string[] -): ReadonlyMap { - const reader: HostPluginRegistryReader = { - read: async () => ({ - location: REGISTRY, - refs: new Map(refs.map((ref) => [ref, { enabled: true }])), - }), - }; - return new Map([["claude", reader]]); -} - -const REGISTRY = "/home/dev/.claude/plugins/installed_plugins.json"; -const REF = "aidd-telemetry@aidd-framework"; - -describe("DiagnoseTelemetryUseCase — what the host will actually load", () => { - it("says a plugin the host's registry carries is registered", async () => { - const { useCase } = buildUseCase({ - manifestRepo: manifestWithClaudePlugin("aidd-framework"), - hostRegistries: registryCarrying([REF]), - }); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.hostRegistration.entries[0]?.answer).toBe("registered"); - }); - - it("says a plugin the registry lacks is not registered, and names the file", async () => { - const { useCase } = buildUseCase({ - manifestRepo: manifestWithClaudePlugin("aidd-framework"), - hostRegistries: registryCarrying([]), - }); - - const entry = (await useCase.execute(runOptions())).setup.hostRegistration.entries[0]; - - expect(entry?.answer).toBe("not-registered"); - expect(entry?.detail).toContain(REGISTRY); - }); - - it("cannot ask any registry about a plugin recorded without a marketplace", async () => { - const { useCase } = buildUseCase({ - manifestRepo: manifestWithClaudePlugin(undefined), - hostRegistries: registryCarrying([REF]), - }); - - expect((await useCase.execute(runOptions())).setup.hostRegistration.entries[0]?.answer).toBe( - "unanswerable" - ); - }); - - it("survives a manifest it cannot parse, and says so instead of dying", async () => { - const { useCase } = buildUseCase({ - manifestRepo: new ThrowingManifestRepository( - new TypeError("Cannot read properties of undefined (reading 'map')") - ), - }); - - const registration = (await useCase.execute(runOptions())).setup.hostRegistration; - - expect(registration.manifestUnreadable).toContain("map"); - expect(registration.entries).toEqual([]); - }); -}); - -class RecordingSessionCostReader implements SessionCostReader { - readonly asked: string[] = []; - - constructor(private readonly result: LocalCostReadResult) {} - - async read(sessionId: string): Promise { - this.asked.push(sessionId); - return this.result; - } -} - -function journalOf(session: RunJournal["session"], lines: Partial = {}): RunJournal { - return { boundaries: [], filesWritten: [], taskDeclarations: [], session, ...lines }; -} - -function claimsOf(result: Awaited>) { - if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); - return result; -} - -function useCaseOverIdentity(store: InMemoryPersonIdentityStore): DiagnoseTelemetryUseCase { - return new DiagnoseTelemetryUseCase( - new StubEvidenceReader(), - versionControl(true), - new InMemoryRunJournalReader(), - new Map(), - new StubHookTrustReader(), - store, - new InMemoryTelemetrySink(), - new FakeCurrentVersion("9.9.9-check"), - installedPluginsFromManifest(new InMemoryManifestRepository(Manifest.create())), - new Map() - ); -} - -const HERE = "git@github.com:acme/widgets.git"; - -async function sinkHolding( - vendorId: string, - projectRemote: string -): Promise { - const sink = new InMemoryTelemetrySink(); - await sink.appendRecord( - { - sink_schema_version: 1, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: vendorId, - vendor_field: "session_id", - step_attribution: "unattributed", - project_id: projectRemote, - project_field: "project_remote", - }, - new Date("2026-08-20T09:01:00Z") - ); - return sink; -} - -function journalledHere(vendorId: string): RunJournal { - const session = sessionStart(vendorId); - return journalOf(session && { ...session, project_remote: HERE }); -} - -describe("DiagnoseTelemetryUseCase — an anchor inherited from another project", () => { - it("names the project the stored figures put the anchored session under", async () => { - const { useCase } = buildUseCase({ - journals: [journalledHere("s-old")], - sink: await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"), - }); - - const result = claimsOf( - await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" })) - ); - - const hookFired = result.claims.find((claim) => claim.claim === "hook-fired"); - expect(hookFired?.verdict).toBe("unknown"); - expect(hookFired?.detail).toContain("git@github.com:acme/other.git"); - }); - - it("still fails an anchor the stored figures put in this very project", async () => { - const { useCase } = buildUseCase({ - journals: [journalledHere("s-old")], - sink: await sinkHolding("s-mine", HERE), - }); - - const result = claimsOf( - await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-mine" })) - ); - - expect(result.claims.find((claim) => claim.claim === "hook-fired")?.reason).toBe( - "session-left-no-run-file" - ); - }); - - it("asks the sink nothing about a session this project journalled itself", async () => { - const sink = await sinkHolding("s-1", "git@github.com:acme/other.git"); - const { useCase } = buildUseCase({ - journals: [journalOf(undefined), journalledHere("s-old"), journalledHere("s-1")], - sink, - }); - - const result = claimsOf(await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" }))); - - expect(result.claims.find((claim) => claim.claim === "hook-fired")?.verdict).toBe("ok"); - expect(sink.vendorsRead).toStrictEqual([]); - }); - - it("asks the sink nothing when no anchor names a session at all", async () => { - const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); - const { useCase } = buildUseCase({ journals: [journalledHere("s-old")], sink }); - - claimsOf(await useCase.execute(runOptions())); - - expect(sink.vendorsRead).toStrictEqual([]); - }); - - it("asks the sink nothing when no run file here names a project to compare against", async () => { - const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); - const { useCase } = buildUseCase({ journals: [journalOf(sessionStart("s-old"))], sink }); - - const result = claimsOf( - await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" })) - ); - - expect(result.claims.find((claim) => claim.claim === "hook-fired")?.reason).toBe( - "session-left-no-run-file" - ); - expect(sink.vendorsRead).toStrictEqual([]); - }); - - it("asks the sink about the anchor alone", async () => { - const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); - const { useCase } = buildUseCase({ journals: [journalledHere("s-old")], sink }); - - claimsOf(await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" }))); - - expect(sink.vendorsRead).toStrictEqual(["s-elsewhere"]); - }); -}); - -describe("DiagnoseTelemetryUseCase — the setup it prints", () => { - it("names the sink's own root as where records land", async () => { - const { useCase } = buildUseCase({}); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.recordsLocation).toStrictEqual({ path: "/fake/telemetry" }); - }); - - it("reports nobody chose an identity as unattached and readable, at the store's own path", async () => { - const { useCase } = buildUseCase({}); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.identity).toStrictEqual({ - attached: false, - path: "/fake/home/.config/aidd/identity.json", - readable: true, - }); - }); - - it("reports a chosen identity as attached", async () => { - const useCase = useCaseOverIdentity( - new InMemoryPersonIdentityStore({ personId: "person-1", origin: "minted", alsoMe: [] }) - ); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.identity).toStrictEqual({ - attached: true, - path: "/fake/home/.config/aidd/identity.json", - readable: true, - }); - }); - - it("reports a damaged identity file as unreadable and unattached, never crashing", async () => { - const store = new InMemoryPersonIdentityStore(null); - store.throwOnRead = new Error("identity.json is a directory"); - - const result = await useCaseOverIdentity(store).execute(runOptions()); - - expect(result.setup.identity).toStrictEqual({ - attached: false, - path: "/fake/home/.config/aidd/identity.json", - readable: false, - }); - }); - - it("says exactly why a non-repository is gated, blaming no hook", async () => { - const { useCase } = buildUseCase({ isRepository: false }); - - const result = await useCase.execute(runOptions()); - - expect(result.gate).toBe( - "not a git repository — the hook has nowhere to write here, not a hook that failed to fire" - ); - }); - - it("names the uncovered tools exactly, each with its declaration's own reason", async () => { - const { useCase } = buildUseCase({}); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.uncovered).toStrictEqual([ - { tool: "cursor", reason: "It writes no token count in any file it produces." }, - { - tool: "kilo", - reason: "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", - }, - ]); - }); -}); - -describe("DiagnoseTelemetryUseCase — reading the host registries", () => { - it("asks no registry for a tool the manifest records no plugin for", async () => { - let codexReads = 0; - const codexRegistry: HostPluginRegistryReader = { - read: async () => { - codexReads += 1; - return { location: "/home/dev/.codex/config.toml", refs: new Map() }; - }, - }; - const registries = new Map(registryCarrying([REF])); - registries.set("codex", codexRegistry); - const { useCase } = buildUseCase({ - manifestRepo: manifestWithClaudePlugin("aidd-framework"), - hostRegistries: registries, - }); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.hostRegistration.entries.map((entry) => entry.tool)).toStrictEqual([ - "claude", - ]); - expect(codexReads).toBe(0); - }); - - it("answers unanswerable for a host that keeps a registry nothing here reads", async () => { - const { useCase } = buildUseCase({ - manifestRepo: manifestWithClaudePlugin("aidd-framework"), - hostRegistries: new Map(), - }); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.hostRegistration).toStrictEqual({ - entries: [ - { - tool: "claude", - plugin: "aidd-telemetry", - ref: REF, - answer: "unanswerable", - detail: "claude keeps a plugin registry, and nothing here has established its shape", - }, - ], - }); - }); - - it("answers unanswerable for a tool that declares no native activation at all", async () => { - const manifest = Manifest.create(); - manifest.addTool("cursor", "test", []); - manifest.addPlugin( - "cursor", - InstalledPlugin.fromMetadata( - "aidd-telemetry", - "1.0.0", - { kind: "github", repo: "ai-driven-dev/framework" }, - true, - "project", - "aidd-framework" - ) - ); - const { useCase } = buildUseCase({ - manifestRepo: new InMemoryManifestRepository(manifest), - hostRegistries: new Map(), - }); - - const result = await useCase.execute(runOptions()); - - expect(result.setup.hostRegistration).toStrictEqual({ - entries: [ - { - tool: "cursor", - plugin: "aidd-telemetry", - ref: REF, - answer: "unanswerable", - detail: "cursor declares no plugin registry to read", - }, - ], - }); - }); -}); - -describe("DiagnoseTelemetryUseCase — what each journal contributes to the claims", () => { - it("survives a journal whose session_start line is torn away, still naming the version another stamped", async () => { - const { useCase } = buildUseCase({ - journals: [ - journalOf(undefined), - journalOf({ - type: "session_start", - at: "2026-08-20T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: "s-1", - plugin_version: "0.3.0", - }), - ], - }); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.setup.versions.plugin).toStrictEqual({ kind: "recorded", version: "0.3.0" }); - }); - - it("asks each covered tool about the sessions the journal names, and about no other", async () => { - const claudeReader = new RecordingSessionCostReader({ records: [], sessionFound: true }); - const { useCase } = buildUseCase({ - journals: [journalOf(undefined), journalOf(sessionStart("s-1"))], - readers: new Map([["claude", claudeReader]]), - }); - - await useCase.execute(runOptions()); - - expect(claudeReader.asked).toStrictEqual(["s-1"]); - }); - - it("summarises the read per covered tool, in the order the tools are declared", async () => { - const claudeReader = new StubSessionCostReader({ records: [], sessionFound: true }); - const { useCase } = buildUseCase({ - journals: [journalOf(sessionStart("s-1"))], - readers: new Map([["claude", claudeReader]]), - }); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.claims[2]).toStrictEqual({ - claim: "tool-files-readable", - verdict: "ok", - reason: "session-found", - detail: - "claude: 1 of 1 session(s) read; copilot: 0 of 1 session(s) read; " + - "opencode: 0 of 1 session(s) read; codex: 0 of 1 session(s) read", - }); - }); - - it("says a journal carrying only session_start closed no turn", async () => { - const { useCase } = buildUseCase({ journals: [journalOf(sessionStart("s-1"))] }); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.claims[1]).toStrictEqual({ - claim: "session-journalled", - verdict: "fail", - reason: "only-session-start", - detail: "1 run file(s), all carrying only session_start — nothing closed the turn", - }); - }); - - it("says a journal carrying any boundary closed its turn", async () => { - const { useCase } = buildUseCase({ - journals: [ - journalOf(sessionStart("s-1"), { - boundaries: [{ type: "turn_end", at: "2026-08-20T09:30:00Z" }], - }), - ], - }); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.claims[1]).toStrictEqual({ - claim: "session-journalled", - verdict: "ok", - reason: "turn-closed", - detail: "1 of 1 run file(s) carry more than session_start", - }); - }); - - it("names the runs directory in the claim that found no run file", async () => { - const { useCase } = buildUseCase({}); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.claims[0]).toStrictEqual({ - claim: "hook-fired", - verdict: "fail", - reason: "recorder-declared-nowhere", - detail: - "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + - "recorder is declared nowhere this build checks", - }); - }); - - it("never asks Codex's hook trust for a session another tool anchors", async () => { - const hookTrustReader = new StubHookTrustReader(); - const { useCase } = buildUseCase({ hookTrustReader }); - - await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })); - - expect(hookTrustReader.reads).toBe(0); - }); - - it("asks Codex's hook trust once for a Codex-anchored session", async () => { - const hookTrustReader = new StubHookTrustReader(); - const { useCase } = buildUseCase({ hookTrustReader }); - - await useCase.execute(runOptions({ CODEX_THREAD_ID: "codex-1" })); - - expect(hookTrustReader.reads).toBe(1); - }); - - it("has no join material when no interval opened and no record states a step", async () => { - const claudeReader = new StubSessionCostReader({ records: [candidate()], sessionFound: true }); - const { useCase } = buildUseCase({ - journals: [journalOf(sessionStart("s-1"))], - readers: new Map([["claude", claudeReader]]), - }); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.claims[3]).toStrictEqual({ - claim: "records-join", - verdict: "unknown", - reason: "no-join-material", - detail: "no step interval and no tool-stated step — see session journalled", - }); - }); - - it("joins a record whose tool stated its step, with no interval to judge it against", async () => { - const claudeReader = new StubSessionCostReader({ - records: [candidate({ step: "aidd-dev:01-plan" })], - sessionFound: true, - }); - const { useCase } = buildUseCase({ - journals: [journalOf(sessionStart("s-1"))], - readers: new Map([["claude", claudeReader]]), - }); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.claims[3]).toStrictEqual({ - claim: "records-join", - verdict: "ok", - reason: "records-joined", - detail: "1 of 1 record(s) joined a step, 0 unattributed", - }); - }); - - it("reads no record at all from a reader that threw", async () => { - const brokenReader = new StubSessionCostReader(undefined, "ENOENT: no such file"); - const { useCase } = buildUseCase({ - journals: [journalOf(sessionStart("s-1"))], - readers: new Map([["claude", brokenReader]]), - }); - - const result = claimsOf(await useCase.execute(runOptions())); - - expect(result.claims[3]).toStrictEqual({ - claim: "records-join", - verdict: "unknown", - reason: "no-record-to-join", - detail: "no record read to join", - }); - }); -}); diff --git a/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts deleted file mode 100644 index 36c4fc811..000000000 --- a/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ForgetTelemetryUseCase } from "../../../../src/contexts/telemetry/application/forget-telemetry-use-case.js"; -import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; -import { telemetryRemovalIsEmpty } from "../../../../src/contexts/telemetry/domain/telemetry-removal.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { noGit } from "../../../contexts/framework/application/helpers.js"; -import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; -import { InMemoryRunJournalReader } from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; - -const PROJECT_ROOT = "/repo"; -const RUNS_ENTRY = "aidd_docs/runs/"; - -// "Inside a repository, nothing tracked yet", the common case, so `history` reads `"possible"` -// by default. Tests about history itself override the git answers explicitly. -const insideRepoNoTracking: VersionControl = { ...noGit, isRepository: async () => true }; - -const RECORD: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "export", - tool: "claude", - vendor_id: "s-1", - vendor_field: "session.id", - cost_usd: 1, - step_attribution: "unattributed", -}; - -function buildUseCase(git: VersionControl = insideRepoNoTracking) { - const sink = new InMemoryTelemetrySink(); - const runJournalReader = new InMemoryRunJournalReader(); - const identity = new InMemoryPersonIdentityStore(); - const useCase = new ForgetTelemetryUseCase(sink, runJournalReader, identity, git); - return { sink, runJournalReader, identity, useCase }; -} - -describe("ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched", () => { - it("names the journal as this project's own, at its own resolved path", async () => { - const { runJournalReader, useCase } = buildUseCase(); - runJournalReader.runFileNames = ["01ARZ3__abc.jsonl"]; - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.journal).toEqual({ - scope: "project", - path: runJournalReader.runsDir, - runFileNames: ["01ARZ3__abc.jsonl"], - }); - }); - - it("names the sink as this machine's own, spanning whatever it holds", async () => { - const { sink, useCase } = buildUseCase(); - await sink.appendRecord(RECORD, new Date("2026-08-20T00:00:00.000Z")); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.sink.scope).toBe("machine"); - expect(preview.sink.path).toBe(sink.rootDir); - expect(preview.sink.dayFileNames).toEqual(["2026-08-20.jsonl"]); - }); - - it("reports an opted-in identity as present", async () => { - const { identity, useCase } = buildUseCase(); - await identity.mint(); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.identity).toEqual({ - scope: "machine", - path: identity.filePath, - present: true, - unreadable: false, - }); - }); - - it("reports a damaged identity file as present, not absent", async () => { - const { identity, useCase } = buildUseCase(); - identity.throwOnRead = new Error("torn file"); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.identity.present).toBe(true); - expect(preview.identity.unreadable).toBe(true); - }); - - it("reports no identity at all as absent", async () => { - const { useCase } = buildUseCase(); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.identity.present).toBe(false); - }); - - it("reads history at its true strength: committed reads as certain", async () => { - const git = { - ...insideRepoNoTracking, - listTrackedFiles: async () => ["aidd_docs/runs/committed.jsonl"], - hasHistoryFor: async () => true, - }; - const { useCase } = buildUseCase(git); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.history).toEqual({ - certainty: "committed", - files: ["aidd_docs/runs/committed.jsonl"], - }); - }); - - it("reads a staged-but-never-committed journal honestly — tracked, not certainly held", async () => { - // `git add`ed but never committed: the index says tracked while history holds nothing for it, - // the gap `git ls-files` alone cannot see. - const git = { - ...insideRepoNoTracking, - listTrackedFiles: async () => ["aidd_docs/runs/staged.jsonl"], - hasHistoryFor: async () => false, - }; - const { useCase } = buildUseCase(git); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.history).toEqual({ - certainty: "staged", - files: ["aidd_docs/runs/staged.jsonl"], - }); - }); - - it("reads an untracked journal as possible, never as an all-clear", async () => { - const { useCase } = buildUseCase(); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.history).toEqual({ certainty: "possible" }); - }); - - it("reads a non-repository as no history at all, never as a possibility", async () => { - const git = { ...insideRepoNoTracking, isRepository: async () => false }; - const { useCase } = buildUseCase(git); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.history).toEqual({ certainty: "none" }); - }); - - it("asks listTrackedFiles and hasHistoryFor about the journal's own pathspec", async () => { - const seenTracked: { repoRoot: string; pathspec: string }[] = []; - const seenHistory: { repoRoot: string; pathspec: string }[] = []; - const git: VersionControl = { - ...insideRepoNoTracking, - listTrackedFiles: async (repoRoot: string, pathspec: string) => { - seenTracked.push({ repoRoot, pathspec }); - return []; - }, - hasHistoryFor: async (repoRoot: string, pathspec: string) => { - seenHistory.push({ repoRoot, pathspec }); - return false; - }, - }; - const { useCase } = buildUseCase(git); - await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(seenTracked).toEqual([{ repoRoot: PROJECT_ROOT, pathspec: RUNS_ENTRY }]); - expect(seenHistory).toEqual([{ repoRoot: PROJECT_ROOT, pathspec: RUNS_ENTRY }]); - }); - - it("a machine where nothing was ever measured has nothing to remove, and offers nothing", async () => { - const { useCase } = buildUseCase(); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(telemetryRemovalIsEmpty(preview)).toBe(true); - }); - - it("touches nothing: previewing leaves the sink, the journal and the identity exactly as they were", async () => { - const { sink, runJournalReader, identity, useCase } = buildUseCase(); - await sink.appendRecord(RECORD, new Date("2026-08-20T00:00:00.000Z")); - runJournalReader.runFileNames = ["01ARZ3__abc.jsonl"]; - await identity.mint(); - - await useCase.preview({ projectRoot: PROJECT_ROOT }); - - expect(sink.deletedFiles).toEqual([]); - expect(await sink.listDayFiles()).toEqual(["2026-08-20.jsonl"]); - expect(runJournalReader.runFileNames).toEqual(["01ARZ3__abc.jsonl"]); - expect(identity.forgetCount).toBe(0); - expect(await identity.read()).not.toBeNull(); - }); -}); - -describe("ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution", () => { - it("removes exactly the run files, day files and identity the preview named, and reports matching counts", async () => { - const { sink, runJournalReader, identity, useCase } = buildUseCase(); - await sink.appendRecord(RECORD, new Date("2026-08-19T00:00:00.000Z")); - await sink.appendRecord(RECORD, new Date("2026-08-20T00:00:00.000Z")); - runJournalReader.runFileNames = ["01ARZ3__abc.jsonl"]; - await identity.mint(); - - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - const result = await useCase.remove(preview); - - expect(result.journal).toEqual({ removed: 1, failed: [] }); - expect(result.sink).toEqual({ removed: 2, failed: [] }); - expect(result.identity).toEqual({ removed: 1, failed: [] }); - expect(await sink.listDayFiles()).toEqual([]); - expect(runJournalReader.runFileNames).toEqual([]); - expect(await identity.read()).toBeNull(); - }); - - it("a location that refuses removal is reported, and every other location is still emptied", async () => { - const { sink, runJournalReader, identity, useCase } = buildUseCase(); - await sink.appendRecord(RECORD, new Date("2026-08-19T00:00:00.000Z")); - await sink.appendRecord(RECORD, new Date("2026-08-20T00:00:00.000Z")); - sink.undeletable.add("2026-08-19.jsonl"); - runJournalReader.runFileNames = ["01ARZ3__abc.jsonl"]; - await identity.mint(); - - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - const result = await useCase.remove(preview); - - expect(result.sink.removed).toBe(1); - expect(result.sink.failed).toEqual([ - { path: "2026-08-19.jsonl", reason: "cannot delete 2026-08-19.jsonl" }, - ]); - // The other locations were not spared by the sink's failure. - expect(result.journal).toEqual({ removed: 1, failed: [] }); - expect(result.identity).toEqual({ removed: 1, failed: [] }); - expect(await sink.listDayFiles()).toEqual(["2026-08-19.jsonl"]); - }); - - it("a journal run file that refuses removal is reported, and the sink still empties", async () => { - const { sink, runJournalReader, identity, useCase } = buildUseCase(); - await sink.appendRecord(RECORD, new Date("2026-08-20T00:00:00.000Z")); - runJournalReader.runFileNames = ["01ARZ3__abc.jsonl"]; - runJournalReader.undeletable.add("01ARZ3__abc.jsonl"); - await identity.mint(); - - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - const result = await useCase.remove(preview); - - expect(result.journal.removed).toBe(0); - expect(result.journal.failed).toEqual([ - { path: "01ARZ3__abc.jsonl", reason: "cannot delete 01ARZ3__abc.jsonl" }, - ]); - expect(result.sink).toEqual({ removed: 1, failed: [] }); - expect(identity.forgetCount).toBe(1); - }); - - it("an identity that refuses removal is reported, and the other locations still empty", async () => { - const { sink, identity, useCase } = buildUseCase(); - await sink.appendRecord(RECORD, new Date("2026-08-20T00:00:00.000Z")); - await identity.mint(); - identity.throwOnForget = new Error("permission denied"); - - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - const result = await useCase.remove(preview); - - expect(result.identity).toEqual({ - removed: 0, - failed: [{ path: identity.filePath, reason: "permission denied" }], - }); - expect(result.sink).toEqual({ removed: 1, failed: [] }); - expect(await sink.listDayFiles()).toEqual([]); - }); - - it("repeats history unchanged after removing — history is not made reachable by removing the rest", async () => { - const git = { - ...insideRepoNoTracking, - listTrackedFiles: async () => ["aidd_docs/runs/committed.jsonl"], - hasHistoryFor: async () => true, - }; - const { runJournalReader, useCase } = buildUseCase(git); - runJournalReader.runFileNames = ["committed.jsonl"]; - - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - const result = await useCase.remove(preview); - - expect(result.history).toEqual(preview.history); - expect(result.history).toEqual({ - certainty: "committed", - files: ["aidd_docs/runs/committed.jsonl"], - }); - }); - - it("proves the guarantee by mutation: removal acts on the preview's own names, never a fresh directory listing", async () => { - const { sink, useCase } = buildUseCase(); - await sink.appendRecord(RECORD, new Date("2026-08-19T00:00:00.000Z")); - await sink.appendRecord(RECORD, new Date("2026-08-20T00:00:00.000Z")); - const realPreview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(realPreview.sink.dayFileNames).toEqual(["2026-08-19.jsonl", "2026-08-20.jsonl"]); - - // A person was shown only one of the two day files, standing in for a preview built before the - // second appeared. Handing this to `remove()` must delete only what it names. - const shownOnlyOneFile = { - ...realPreview, - sink: { ...realPreview.sink, dayFileNames: ["2026-08-19.jsonl"] }, - }; - - await useCase.remove(shownOnlyOneFile); - - // The file never named in what was shown survives: a fresh `listDayFiles()` inside `remove()` - // would have deleted it anyway, which is the failure this design exists to make impossible. - expect(await sink.listDayFiles()).toEqual(["2026-08-20.jsonl"]); - expect(sink.deletedFiles).toEqual(["2026-08-19.jsonl"]); - }); - - it("proves the guarantee by mutation for the journal: removal acts on the preview's own directory, never the reader's own resolution", async () => { - const { runJournalReader, useCase } = buildUseCase(); - runJournalReader.runFileNames = ["01ARZ3__abc.jsonl"]; - const realPreview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(realPreview.journal.path).toBe(runJournalReader.runsDir); - - // A relocated `AIDD_RUNS_DIR` between preview and remove changes what - // `runJournalReader.runsDir` answers next, but never what was already shown. - const shownElsewhere = { - ...realPreview, - journal: { ...realPreview.journal, path: "/elsewhere/relocated-runs" }, - }; - - await useCase.remove(shownElsewhere); - - expect(runJournalReader.deletedFromDirs).toEqual(["/elsewhere/relocated-runs"]); - expect(runJournalReader.deletedFromDirs).not.toContain(runJournalReader.runsDir); - }); - - it("proves the guarantee by mutation for the identity: removal acts on the preview's own path, never the store's own resolution", async () => { - const { identity, useCase } = buildUseCase(); - await identity.mint(); - const realPreview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(realPreview.identity.path).toBe(identity.filePath); - - // A relocated `HOME` between preview and remove would change what `identity.filePath` - // answers on the next call, but never what was already shown. - const shownElsewhere = { - ...realPreview, - identity: { ...realPreview.identity, path: "/elsewhere/relocated-identity.json" }, - }; - - await useCase.remove(shownElsewhere); - - expect(identity.forgetCalledWithPath).toBe("/elsewhere/relocated-identity.json"); - }); - - it("never touches an identity that was not shown in the preview — the gate on preview.identity.present", async () => { - const { identity, useCase } = buildUseCase(); - const preview = await useCase.preview({ projectRoot: PROJECT_ROOT }); - expect(preview.identity.present).toBe(false); - - // A file appears AFTER the preview was shown and said "nothing to remove" — this must - // never be reached, let alone deleted and counted as removed. - identity.filePresent = true; - - const result = await useCase.remove(preview); - - expect(result.identity).toEqual({ removed: 0, failed: [] }); - expect(identity.forgetCount).toBe(0); - expect(identity.forgetCalledWithPath).toBeNull(); - }); -}); diff --git a/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts deleted file mode 100644 index 5fa6f50b2..000000000 --- a/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { PersonIdentityUseCase } from "../../../../src/contexts/telemetry/application/person-identity-use-case.js"; -import { - EmptyDisplayNameError, - EmptyIdentifierError, - IdentityRequiredToLinkError, - UnreadableIdentityFileError, -} from "../../../../src/kernel/errors.js"; -import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; - -function useCase(store: InMemoryPersonIdentityStore): PersonIdentityUseCase { - return new PersonIdentityUseCase(store); -} - -describe("PersonIdentityUseCase.status", () => { - it("answers no identity when nobody opted in", async () => { - const result = await useCase(new InMemoryPersonIdentityStore(null)).status(); - - expect(result.identity).toBeNull(); - }); - - it("answers an identity with no name and no added identifiers", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).status(); - - expect(result.identity).toEqual({ personId: "person-1", origin: "minted", alsoMe: [] }); - }); - - it("answers an identity with a name", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: [], - displayName: "Baptiste", - }); - - const result = await useCase(store).status(); - - expect(result.identity).toEqual({ - personId: "person-1", - origin: "minted", - alsoMe: [], - displayName: "Baptiste", - }); - }); - - it("throws, never answers 'no identity', when the store cannot be read", async () => { - const store = new InMemoryPersonIdentityStore(null); - store.throwOnRead = new Error("identity.json is a directory"); - - await expect(useCase(store).status()).rejects.toThrow("identity.json is a directory"); - }); - - it("lists every identifier added onto this person, including how it was obtained", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "adopted", - alsoMe: ["a-second-machine"], - }); - - const result = await useCase(store).status(); - - expect(result.identity?.origin).toBe("adopted"); - expect(result.identity?.alsoMe).toEqual(["a-second-machine"]); - }); -}); - -describe("PersonIdentityUseCase.use, settling which identifier stands", () => { - it("mints an identifier when none exists", async () => { - const store = new InMemoryPersonIdentityStore(null, "fresh-id"); - - const result = await useCase(store).use({}); - - expect(result.outcome).toBe("minted"); - expect(result.identity).toEqual({ personId: "fresh-id", origin: "minted", alsoMe: [] }); - expect(store.mintCount).toBe(1); - }); - - it("a second on reports the same identifier, never a new one", async () => { - const store = new InMemoryPersonIdentityStore( - { personId: "existing-id", origin: "minted", alsoMe: [] }, - "fresh-id" - ); - - const result = await useCase(store).use({}); - - expect(result.outcome).toBe("unchanged"); - expect(result.identity).toEqual({ personId: "existing-id", origin: "minted", alsoMe: [] }); - expect(store.mintCount).toBe(0); - }); -}); - -describe("PersonIdentityUseCase.use", () => { - it("refuses an empty or whitespace-only identifier, writing nothing", async () => { - const store = new InMemoryPersonIdentityStore(null); - - await expect(useCase(store).use({ identifier: " " })).rejects.toThrow(EmptyIdentifierError); - expect(await store.read()).toBeNull(); - }); - - it("takes an identifier minted elsewhere, recording it as adopted", async () => { - const store = new InMemoryPersonIdentityStore(null); - - const result = await useCase(store).use({ identifier: "machine-1-id" }); - - expect(result.outcome).not.toBe("unchanged"); - expect(result.identity).toEqual({ personId: "machine-1-id", origin: "adopted", alsoMe: [] }); - expect(result.replacedPersonId).toBeUndefined(); - }); - - it("reports the identifier already in effect, and writes nothing", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "machine-1-id", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).use({ identifier: "machine-1-id" }); - - expect(result.outcome).toBe("unchanged"); - expect(await store.read()).toEqual({ - personId: "machine-1-id", - origin: "minted", - alsoMe: [], - }); - }); - - it("replaces a different identifier, naming what it replaced", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "old-id", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).use({ identifier: "new-id" }); - - expect(result.outcome).not.toBe("unchanged"); - expect(result.replacedPersonId).toBe("old-id"); - expect(result.identity.personId).toBe("new-id"); - expect(result.identity.origin).toBe("adopted"); - }); - - it("keeps alsoMe already declared when adopting a different identifier", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "old-id", - origin: "minted", - alsoMe: ["kept-identifier"], - }); - - const result = await useCase(store).use({ identifier: "new-id" }); - - expect(result.identity.alsoMe).toEqual(["kept-identifier"]); - }); -}); - -describe("PersonIdentityUseCase.link", () => { - it("refuses an empty or whitespace-only identifier, writing nothing", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "minted", - alsoMe: [], - }); - - await expect(useCase(store).link(" ")).rejects.toThrow(EmptyIdentifierError); - expect((await store.read())?.alsoMe).toEqual([]); - }); - - it("refuses when nobody opted in, naming the missing step", async () => { - const uc = useCase(new InMemoryPersonIdentityStore(null)); - - await expect(uc.link("some-other-machine-id")).rejects.toThrow(IdentityRequiredToLinkError); - await expect(uc.link("some-other-machine-id")).rejects.toThrow(/telemetry identity use/u); - }); - - it("reports the person's own identifier as already listed, and appends nothing onto alsoMe", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).link("person-a"); - - expect(result.alreadyListed).toBe(true); - expect((await store.read())?.alsoMe).toEqual([]); - }); - - it("adds the identifier onto this person", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).link("machine-2"); - - expect(result.alreadyListed).toBe(false); - expect(result.personId).toBe("person-a"); - expect((await store.read())?.alsoMe).toEqual(["machine-2"]); - }); - - it("reports an identifier already listed as already listed, not as a second write", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "minted", - alsoMe: ["machine-2"], - }); - - const result = await useCase(store).link("machine-2"); - - expect(result.alreadyListed).toBe(true); - }); -}); - -describe("PersonIdentityUseCase.unlink", () => { - it("reports nothing to remove for an empty identifier, never as a failure - link already refuses to write one", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).unlink(""); - - expect(result.removed).toBe(false); - }); - - it("reports nothing to remove for an identifier nobody listed, and exits successfully", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).unlink("never-linked"); - - expect(result.removed).toBe(false); - }); - - it("withdraws an identifier from this person", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "minted", - alsoMe: ["machine-2"], - }); - - const result = await useCase(store).unlink("machine-2"); - - expect(result.removed).toBe(true); - expect((await store.read())?.alsoMe).toEqual([]); - }); - - it("reports nothing to remove when nobody opted in at all - off already took the alsoMe list with it", async () => { - const result = await useCase(new InMemoryPersonIdentityStore(null)).unlink("machine-2"); - - expect(result.removed).toBe(false); - }); -}); - -describe("PersonIdentityUseCase.use, minted apart from adopted", () => { - /** - * An identifier this machine minted gets the disclosure about what it will attach to; one - * carried here from another machine has to say what it replaced instead. - */ - it("calls a fresh identifier minted, and one carried here adopted", async () => { - const minted = await useCase(new InMemoryPersonIdentityStore(null)).use({}); - const adopted = await useCase(new InMemoryPersonIdentityStore(null)).use({ - identifier: "from-another-machine", - }); - - expect(minted.outcome).toBe("minted"); - expect(adopted.outcome).toBe("adopted"); - expect(minted.identity.origin).toBe("minted"); - expect(adopted.identity.origin).toBe("adopted"); - }); - - it("calls replacing one identifier with another adopted, never minted", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).use({ identifier: "person-2" }); - - expect(result.outcome).toBe("adopted"); - expect(result.replacedPersonId).toBe("person-1"); - }); -}); - -describe("PersonIdentityUseCase.use, attaching a display name", () => { - it("mints an identifier for a name given when none stands, rather than refusing", async () => { - // `--name` is a property of what `use` settles on: a person typing `identity use --name Ada` - // with nothing standing is saying who they are, not renaming a thing that is not there. - const store = new InMemoryPersonIdentityStore(null); - - const result = await useCase(store).use({ displayName: "Ada" }); - - expect(result.outcome).toBe("minted"); - expect(result.displayNameSet).toBe("Ada"); - expect(result.identity.origin).toBe("minted"); - }); - - it("refuses an empty or whitespace-only value", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: [], - }); - - await expect(useCase(store).use({ displayName: " " })).rejects.toThrow(EmptyDisplayNameError); - }); - - it("attaches the display name beside the identifier already opted into", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: [], - }); - - await useCase(store).use({ displayName: "Baptiste" }); - - expect(await store.read()).toEqual({ - personId: "person-1", - origin: "minted", - alsoMe: [], - displayName: "Baptiste", - }); - }); -}); - -describe("PersonIdentityUseCase.off", () => { - it("states that new records will carry no person, and removes the file", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: [], - }); - - const result = await useCase(store).off(); - - expect(result.removed).toBe(true); - expect(store.forgetCount).toBe(1); - expect(await store.read()).toBeNull(); - }); - - it("does nothing when already off", async () => { - const result = await useCase(new InMemoryPersonIdentityStore(null)).off(); - - expect(result.removed).toBe(false); - expect(result.addedIdentifiersRemoved).toBe(0); - }); - - // A file holding an empty `person_id` parses to "nobody chose" while still sitting on - // disk, and withdrawing removes the whole declaration. - it("removes a file that exists but names nobody, rather than reading it as already off", async () => { - const store = new InMemoryPersonIdentityStore(null); - store.filePresent = true; - - const result = await useCase(store).off(); - - expect(result.removed).toBe(true); - expect(store.forgetCount).toBe(1); - expect(store.filePresent).toBe(false); - }); - - it("opting in again after withdrawing mints a fresh identifier, never the old one back", async () => { - const store = new InMemoryPersonIdentityStore( - { personId: "withdrawn-id", origin: "minted", alsoMe: [] }, - "fresh-id" - ); - const uc = useCase(store); - - await uc.off(); - const result = await uc.use({}); - - expect(result.outcome).toBe("minted"); - expect(result.identity.personId).toBe("fresh-id"); - expect(result.identity.personId).not.toBe("withdrawn-id"); - }); - - // The store reads back nothing because the file is a directory — a shape that cannot also - // parse to an identity, so `removed` can only come from the filesystem. - it("discards a damaged identity file rather than leaving a person unable to withdraw", async () => { - const store = new InMemoryPersonIdentityStore(null); - store.filePresent = true; - store.throwOnRead = new UnreadableIdentityFileError(store.filePath, "EISDIR"); - - const result = await useCase(store).off(); - - expect(result.removed).toBe(true); - expect(result.discardedDamaged).toBe(true); - expect(store.forgetCount).toBe(1); - }); - - it("still throws off's own way for anything that is not the store's own unreadable error", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: [], - }); - store.throwOnRead = new Error("some other failure entirely"); - - await expect(useCase(store).off()).rejects.toThrow("some other failure entirely"); - expect(store.forgetCount).toBe(0); - }); - - it("removes the whole declaration, stating how many added identifiers went with it", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: ["a-second-machine", "a-third-machine"], - }); - - const result = await useCase(store).off(); - - expect(result.addedIdentifiersRemoved).toBe(2); - expect(await store.read()).toBeNull(); - }); -}); - -/** - * Every command an error names must be a command the CLI still has, and no gate typechecks - * a message — so this scans the source rather than a list that goes stale the same way. - */ -describe("what the errors tell a person to run", () => { - /** The verbs `registerTelemetryIdentityCommand` actually registers, plus the bare noun. */ - const IDENTITY_VERBS = new Set(["", "use", "off", "link", "unlink"]); - - it("names no identity verb the command surface does not have", () => { - const source = readFileSync( - new URL("../../../../src/kernel/errors.ts", import.meta.url), - "utf8" - ); - const named = [...source.matchAll(/aidd telemetry identity ?([a-z-]*)/gu)].map( - (match) => match[1] ?? "" - ); - - // Non-empty: a scan that found nothing would pass forever while saying nothing. - expect(named.length).toBeGreaterThan(0); - for (const verb of named) { - expect(IDENTITY_VERBS, `errors.ts names \`identity ${verb}\``).toContain(verb); - } - }); -}); - -class AddCountingStore extends InMemoryPersonIdentityStore { - addAlsoMeCalls = 0; - - override async addAlsoMe(identity: string) { - this.addAlsoMeCalls += 1; - return super.addAlsoMe(identity); - } -} - -describe("PersonIdentityUseCase — the exact shape of what it answers", () => { - it("names the verb in the refusal of an empty identifier to use", async () => { - await expect( - useCase(new InMemoryPersonIdentityStore(null)).use({ identifier: " " }) - ).rejects.toThrow("`aidd telemetry identity use` needs a non-empty value."); - }); - - it("names the verb in the refusal of an empty identifier to link", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "minted", - alsoMe: [], - }); - - await expect(useCase(store).link(" ")).rejects.toThrow( - "`aidd telemetry identity link` needs a non-empty value." - ); - }); - - it("answers a minted identifier with no replaced identifier and no display name set", async () => { - const store = new InMemoryPersonIdentityStore(null, "fresh-id"); - - const result = await useCase(store).use({}); - - expect(result).toStrictEqual({ - filePath: "/fake/home/.config/aidd/identity.json", - identity: { personId: "fresh-id", origin: "minted", alsoMe: [] }, - outcome: "minted", - }); - }); - - it("writes no display name key at all when none was asked for", async () => { - const store = new InMemoryPersonIdentityStore(null, "fresh-id"); - - await useCase(store).use({}); - - expect(await store.read()).toStrictEqual({ - personId: "fresh-id", - origin: "minted", - alsoMe: [], - }); - }); - - it("writes onto alsoMe exactly once for a new identifier, and never for one already listed", async () => { - const store = new AddCountingStore({ - personId: "person-a", - origin: "minted", - alsoMe: ["machine-2"], - }); - const uc = useCase(store); - - await uc.link("machine-2"); - await uc.link("person-a"); - await uc.link("machine-3"); - - expect(store.addAlsoMeCalls).toBe(1); - }); - - it("answers a clean withdrawal as not discarding anything damaged", async () => { - const store = new InMemoryPersonIdentityStore({ - personId: "person-1", - origin: "minted", - alsoMe: ["a-second-machine"], - }); - - const result = await useCase(store).off(); - - expect(result).toStrictEqual({ - filePath: "/fake/home/.config/aidd/identity.json", - removed: true, - discardedDamaged: false, - addedIdentifiersRemoved: 1, - }); - }); -}); diff --git a/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts b/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts deleted file mode 100644 index 680b67ca4..000000000 --- a/cli/tests/contexts/telemetry/application/read-local-cost-sweep.unit.test.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { - type LocalCostToolReport, - ReadLocalCostUseCase, -} from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; -import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import type { - LocalCostCandidateRecord, - SessionCostReader, -} from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import type { AiToolId } from "../../../../src/kernel/tool.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; -import { - InMemoryRunJournalReader, - NULL_RUN_JOURNAL_READER, -} from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -const PROJECT_ROOT = "/repo"; -const SESSION_ID = "s-1"; -const TURN_ID = "req_1"; -const AT = new Date("2026-08-20T12:00:00Z"); -const EVIDENCE_READER = new StubTelemetryEvidenceReader(); - -const CANDIDATE: LocalCostCandidateRecord = { - kind: "request", - vendor_id: SESSION_ID, - vendor_field: "sessionId", - turn_id: TURN_ID, - turn_field: "requestId", - model: "claude-sonnet-5", - input_tokens: 10, - output_tokens: 20, - cache_read_tokens: 30, - cache_creation_tokens: 40, -}; - -const STORED: TelemetrySinkRecord = { - ...CANDIDATE, - sink_schema_version: 2, - provenance: "local-read", - tool: "claude", - step_attribution: "unattributed", -}; - -const NOT_ASKED = { - status: "not-asked", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, -} as const; - -function notAsked(tool: AiToolId): LocalCostToolReport { - return { tool, ...NOT_ASKED }; -} - -const CURSOR_NOT_COVERED: LocalCostToolReport = { - tool: "cursor", - status: "not-covered", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - reason: "It writes no token count in any file it produces.", -}; - -const KILO_NOT_COVERED: LocalCostToolReport = { - tool: "kilo", - status: "not-covered", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - reason: "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", -}; - -function sessionJournal(vendorId: string, host = "claude-code"): RunJournal { - return { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at: "2026-08-20T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: host, - vendor_id: vendorId, - }, - }; -} - -function journalNaming(...vendorIds: readonly string[]): InMemoryRunJournalReader { - const reader = new InMemoryRunJournalReader(); - for (const vendorId of vendorIds) reader.set(vendorId, sessionJournal(vendorId)); - return reader; -} - -type Answer = readonly LocalCostCandidateRecord[] | "absent" | Error; - -function readerAnswering(answers: ReadonlyMap): SessionCostReader { - return { - read: async (sessionId: string) => { - const answer = answers.get(sessionId) ?? "absent"; - if (answer instanceof Error) throw answer; - if (answer === "absent") return { records: [], sessionFound: false }; - return { records: answer, sessionFound: true }; - }, - }; -} - -function claudeOnly(reader: SessionCostReader): ReadonlyMap { - return new Map([["claude", reader]]); -} - -async function sweep( - readers: ReadonlyMap, - journals: InMemoryRunJournalReader | typeof NULL_RUN_JOURNAL_READER, - sink = new InMemoryTelemetrySink() -) { - const useCase = new ReadLocalCostUseCase( - sink, - readers, - journals, - NULL_PERSON_IDENTITY_READER, - EVIDENCE_READER - ); - return useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); -} - -async function readOne( - sink: InMemoryTelemetrySink, - candidates: readonly LocalCostCandidateRecord[] -): Promise { - const result = await new ReadLocalCostUseCase( - sink, - claudeOnly(readerAnswering(new Map([[SESSION_ID, candidates]]))), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - EVIDENCE_READER - ).execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT, sessionId: SESSION_ID }); - const claude = result.toolReports.find((report) => report.tool === "claude"); - if (claude === undefined) throw new Error("no claude report"); - return claude.recordsStored; -} - -function claudeReport(reports: readonly LocalCostToolReport[]): LocalCostToolReport { - const claude = reports.find((report) => report.tool === "claude"); - if (claude === undefined) throw new Error("no claude report"); - return claude; -} - -describe("which sessions a sweep reads", () => { - it("names every session the journal anchors and skips a run file that anchors none", async () => { - const journals = journalNaming("s-a"); - journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); - journals.set("s-b", sessionJournal("s-b")); - - const result = await sweep(claudeOnly(readerAnswering(new Map())), journals); - - expect(result.sessions.map((session) => session.sessionId)).toStrictEqual(["s-a", "s-b"]); - }); - - it("answers not-asked for every tool, and nothing more, when the journal names no session", async () => { - const result = await sweep(claudeOnly(readerAnswering(new Map())), NULL_RUN_JOURNAL_READER); - - expect(result).toStrictEqual({ - sessions: [], - toolReports: [ - notAsked("claude"), - notAsked("cursor"), - notAsked("copilot"), - notAsked("opencode"), - notAsked("kilo"), - notAsked("codex"), - ], - }); - }); - - it("states the refusal in the words a person can act on", async () => { - const refused = new StubTelemetryEvidenceReader(); - refused.enabled = false; - const useCase = new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), - journalNaming(SESSION_ID), - NULL_PERSON_IDENTITY_READER, - refused - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); - - expect(result).toStrictEqual({ - sessions: [], - toolReports: [ - notAsked("claude"), - notAsked("cursor"), - notAsked("copilot"), - notAsked("opencode"), - notAsked("kilo"), - notAsked("codex"), - ], - refusedReason: - "measurement is refused — AIDD_TELEMETRY=0 or the project switch is off; nothing read, " + - "nothing stored", - }); - }); -}); - -describe("one session's answers, tool by tool", () => { - it("lists every tool once, in registry order, each with its own answer", async () => { - const result = await sweep( - claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), - journalNaming(SESSION_ID) - ); - - expect(result.sessions).toStrictEqual([ - { - sessionId: SESSION_ID, - toolReports: [ - { tool: "claude", status: "found", recordsFound: 1, recordsStored: 1, sessionsFailed: 0 }, - CURSOR_NOT_COVERED, - notAsked("copilot"), - notAsked("opencode"), - KILO_NOT_COVERED, - notAsked("codex"), - ], - }, - ]); - }); - - it("reports not-found, never unreadable, for a covered tool that was given no reader", async () => { - const result = await sweep(new Map(), journalNaming(SESSION_ID)); - - expect(claudeReport(result.toolReports)).toStrictEqual({ - tool: "claude", - status: "not-found", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - }); - }); - - it("carries no failure field at all for a tool whose every session read cleanly", async () => { - const result = await sweep( - claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), - journalNaming(SESSION_ID) - ); - - expect(claudeReport(result.toolReports)).toStrictEqual({ - tool: "claude", - status: "found", - recordsFound: 1, - recordsStored: 1, - sessionsFailed: 0, - }); - }); -}); - -describe("the strongest answer a tool gave across a sweep", () => { - it("ranks empty above not-found, whichever session came first", async () => { - const result = await sweep( - claudeOnly( - readerAnswering( - new Map([ - ["s-a", "absent"], - ["s-b", []], - ]) - ) - ), - journalNaming("s-a", "s-b") - ); - - expect(claudeReport(result.toolReports).status).toBe("empty"); - }); - - it("ranks found above empty, so a session that billed nothing cannot hide one that did", async () => { - const result = await sweep( - claudeOnly( - readerAnswering( - new Map([ - ["s-a", []], - ["s-b", [{ ...CANDIDATE, vendor_id: "s-b" }]], - ]) - ) - ), - journalNaming("s-a", "s-b") - ); - - expect(claudeReport(result.toolReports)).toStrictEqual({ - tool: "claude", - status: "found", - recordsFound: 1, - recordsStored: 1, - sessionsFailed: 0, - }); - }); - - it("ranks not-found above not-asked, so a session that never asked cannot outrank one that looked", async () => { - const journals = new InMemoryRunJournalReader(); - journals.set("s-a", sessionJournal("s-a", "codex")); - journals.set("s-b", sessionJournal("s-b")); - - const result = await sweep( - claudeOnly(readerAnswering(new Map([["s-b", "absent"]]))), - journals - ); - - expect(claudeReport(result.toolReports)).toStrictEqual({ - tool: "claude", - status: "not-found", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - }); - }); - - it("keeps the first of two unreadable sessions as the reason and the last as the failure", async () => { - const result = await sweep( - claudeOnly( - readerAnswering( - new Map([ - ["s-a", new Error("first")], - ["s-b", new Error("second")], - ]) - ) - ), - journalNaming("s-a", "s-b") - ); - - expect(claudeReport(result.toolReports)).toStrictEqual({ - tool: "claude", - status: "unreadable", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 2, - reason: "first", - failureReason: "second", - }); - }); - - it("ranks found above unreadable and still counts the session it could not read", async () => { - const result = await sweep( - claudeOnly( - readerAnswering( - new Map([ - ["s-a", new Error("boom")], - ["s-b", [{ ...CANDIDATE, vendor_id: "s-b" }]], - ]) - ) - ), - journalNaming("s-a", "s-b") - ); - - expect(claudeReport(result.toolReports)).toStrictEqual({ - tool: "claude", - status: "found", - recordsFound: 1, - recordsStored: 1, - sessionsFailed: 1, - failureReason: "boom", - }); - }); -}); - -describe("what counts as a correction of a stored turn", () => { - it("stores a reading that adds a counter the stored line never had", async () => { - const sink = new InMemoryTelemetrySink(); - const { cache_creation_tokens: _dropped, ...withoutCacheCreation } = STORED; - await sink.appendRecord(withoutCacheCreation, AT); - - expect(await readOne(sink, [CANDIDATE])).toBe(1); - }); - - it("drops a reading that lost a counter the stored line has, however large the rest", async () => { - const sink = new InMemoryTelemetrySink(); - await sink.appendRecord(STORED, AT); - const { cache_read_tokens: _dropped, ...withoutCacheRead } = CANDIDATE; - - expect(await readOne(sink, [{ ...withoutCacheRead, output_tokens: 900 }])).toBe(0); - }); - - it("drops a reading that grew one counter but shrank another", async () => { - const sink = new InMemoryTelemetrySink(); - await sink.appendRecord(STORED, AT); - - expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 900, input_tokens: 5 }])).toBe(0); - }); - - it("measures a correction against the largest stored reading, not the latest", async () => { - const sink = new InMemoryTelemetrySink(); - await sink.appendRecord({ ...STORED, output_tokens: 900 }, AT); - await sink.appendRecord(STORED, AT); - - expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 500 }])).toBe(0); - }); - - it("measures a correction against the largest stored reading, not the earliest", async () => { - const sink = new InMemoryTelemetrySink(); - await sink.appendRecord(STORED, AT); - await sink.appendRecord({ ...STORED, output_tokens: 900 }, AT); - - expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 500 }])).toBe(0); - }); - - it("measures against the earliest of two equally large stored readings", async () => { - const sink = new InMemoryTelemetrySink(); - await sink.appendRecord(STORED, AT); - await sink.appendRecord({ ...STORED, input_tokens: 20, output_tokens: 10 }, AT); - - expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 21 }])).toBe(1); - }); - - it("never lets a session total correct a request line that shares its turn id", async () => { - const sink = new InMemoryTelemetrySink(); - await sink.appendRecord(STORED, AT); - const sessionTotal: LocalCostCandidateRecord = { - kind: "session", - vendor_id: SESSION_ID, - vendor_field: "sessionId", - turn_id: TURN_ID, - turn_field: "requestId", - input_tokens: 100, - output_tokens: 200, - cache_read_tokens: 300, - cache_creation_tokens: 400, - }; - - expect(await readOne(sink, [sessionTotal])).toBe(0); - }); - - it("drops a turn already stored from an export, without a local-read line to measure against", async () => { - const sink = new InMemoryTelemetrySink(); - await sink.appendRecord({ ...STORED, provenance: "export" }, AT); - - expect(await readOne(sink, [{ ...CANDIDATE, output_tokens: 900 }])).toBe(0); - }); -}); - -class CountingSink extends InMemoryTelemetrySink { - vendorReads = 0; - listingFails = false; - - override async readRecordsForVendor(vendorId: string): Promise { - this.vendorReads += 1; - return super.readRecordsForVendor(vendorId); - } - - override async listDayFiles(): Promise { - if (this.listingFails) throw new Error("day files unlistable"); - return super.listDayFiles(); - } -} - -describe("housekeeping around a sweep", () => { - it("never consults the sink for a session whose reader returned nothing", async () => { - const sink = new CountingSink(); - - await readOne(sink, []); - - expect(sink.vendorReads).toBe(0); - }); - - it("warns, in its own words, when the day files cannot be listed, and still answers", async () => { - const sink = new CountingSink(); - sink.listingFails = true; - const logger = new CapturingLogger(); - const useCase = new ReadLocalCostUseCase( - sink, - claudeOnly(readerAnswering(new Map([[SESSION_ID, [CANDIDATE]]]))), - journalNaming(SESSION_ID), - NULL_PERSON_IDENTITY_READER, - EVIDENCE_READER, - undefined, - logger - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); - - expect(logger.warnMessages).toStrictEqual([ - "telemetry read: retention prune failed - day files unlistable", - ]); - expect(claudeReport(result.toolReports).recordsStored).toBe(1); - }); - - it("warns once per day file it could not delete, naming the file and the cause", async () => { - const sink = new InMemoryTelemetrySink(); - for (const day of ["2026-01-01", "2026-01-02", "2026-08-20"]) { - await sink.appendRecord(STORED, new Date(`${day}T00:00:00Z`)); - } - sink.undeletable.add("2026-01-01.jsonl"); - const logger = new CapturingLogger(); - const useCase = new ReadLocalCostUseCase( - sink, - claudeOnly(readerAnswering(new Map())), - journalNaming(SESSION_ID), - NULL_PERSON_IDENTITY_READER, - EVIDENCE_READER, - undefined, - logger, - 1 - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: AT }); - - expect(logger.warnMessages).toStrictEqual([ - "telemetry read: could not delete 2026-01-01.jsonl - cannot delete 2026-01-01.jsonl", - ]); - expect(sink.deletedFiles).toStrictEqual(["2026-01-02.jsonl"]); - }); - - it("leaves a dated record unattributed when no journal exists to place it in a step", async () => { - const sink = new InMemoryTelemetrySink(); - - await readOne(sink, [{ ...CANDIDATE, event_timestamp: "2026-08-20T10:02:00Z" }]); - - const [stored] = [...sink.files.values()].flat(); - expect(stored).toMatchObject({ step_attribution: "unattributed", step: undefined }); - }); -}); diff --git a/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts deleted file mode 100644 index dea20b167..000000000 --- a/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts +++ /dev/null @@ -1,1636 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -// Side-effect imports: the use-case resolves each tool's local-read declaration from the -// registry, so every AI tool must be registered for these tests to see it. -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; -import { mapCodexRolloutToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; -import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import type { - LocalCostCandidateRecord, - SessionCostReader, -} from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import type { AiTool } from "../../../../src/contexts/tools/domain/contracts.js"; -import { getAiToolConfig, registerTool } from "../../../../src/contexts/tools/domain/registry.js"; -import type { AiToolId } from "../../../../src/kernel/tool.js"; -import { FakeCurrentVersion } from "../../../helpers/ports/fake-current-version.js"; -import { - InMemoryPersonIdentityReader, - NULL_PERSON_IDENTITY_READER, -} from "../../../helpers/ports/in-memory-person-identity-reader.js"; -import { - InMemoryRunJournalReader, - NULL_RUN_JOURNAL_READER, -} from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -const SESSION_ID = "s-1"; -const PROJECT_ROOT = "/repo"; -// The project switch always answers "on" here: every test in this file is about what the -// sink, the journal or a reader hold. Its refusal is covered on its own, below. -const TELEMETRY_EVIDENCE_READER = new StubTelemetryEvidenceReader(); - -function stubReader(records: readonly LocalCostCandidateRecord[]): SessionCostReader { - return { - read: async (sessionId: string) => - sessionId === SESSION_ID - ? { records, sessionFound: true } - : { records: [], sessionFound: false }, - }; -} - -function journalWithTurnEnd(at: string): RunJournal { - return { - boundaries: [{ type: "turn_end", at }], - filesWritten: [], - taskDeclarations: [], - }; -} - -// The same real, redacted rollout excerpt `codex-rollout.unit.test.ts` asserts against, its -// last turn's two `token_count` lines cut to one: a session read while it was still running. -const CODEX_TARGET_ID = "019fae6f-2009-7cd3-86b2-b8f83481b160"; -const CODEX_FIXTURE_PATH = - ".codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl"; -const CODEX_LAST_TURN_ID = "019fae71-ae8b-7850-a982-78d7cd9dba52"; - -function loadCodexFixture(): string { - const url = new URL(`../../../fixtures/local-cost/${CODEX_FIXTURE_PATH}`, import.meta.url); - return readFileSync(fileURLToPath(url), "utf8"); -} - -/** The real Codex reader, fed a different snapshot of the rollout's bytes on each call, as - * `CodexCostReaderAdapter` sees a growing file. The last snapshot repeats once exhausted. */ -function growingCodexReader(...snapshots: readonly string[]): SessionCostReader { - let call = 0; - return { - read: async (sessionId: string) => { - if (sessionId !== CODEX_TARGET_ID) return { records: [], sessionFound: false }; - const content = snapshots[Math.min(call, snapshots.length - 1)]; - call++; - return { records: mapCodexRolloutToSinkRecords(content), sessionFound: true }; - }, - }; -} - -// The full fixture's last turn sums to input 5032 / output 3550 / cache-read 99840; dropping -// its final line leaves only its first increment, input 2816 / output 1401 / cache-read 48896. -function truncatedCodexFixture(): string { - return loadCodexFixture().split("\n").slice(0, 7).join("\n"); -} - -// Shaped like a real Claude Code transcript record, but this file stubs `SessionCostReader` -// throughout: it tests the use-case's orchestration, not any tool's real reader. -const CANDIDATE: LocalCostCandidateRecord = { - kind: "request", - vendor_id: SESSION_ID, - vendor_field: "sessionId", - turn_id: "req_1", - turn_field: "requestId", - model: "claude-sonnet-5", - input_tokens: 10, - output_tokens: 20, - cache_read_tokens: 0, - cache_creation_tokens: 0, -}; - -describe("ReadLocalCostUseCase", () => { - let claudeConfig: AiTool; - - beforeEach(() => { - claudeConfig = getAiToolConfig("claude"); - }); - - afterEach(() => { - // registerTool mutates a module-level registry — restore it so no other test sees a - // "claude declares a local read" world that does not actually ship yet. - registerTool(claudeConfig); - }); - - // The minimum the type requires: what a route supplies is not what this file tests. - const SUPPLIES_NOTHING = { - tokenCounters: false, - amount: false, - toolStatedStep: false, - agentName: false, - } as const; - - function declareClaudeReadable(): void { - registerTool({ - ...claudeConfig, - telemetryLocalRead: { kind: "declared", supplies: SUPPLIES_NOTHING }, - }); - } - - it("carries a covered tool's stated limitation through to the report, since a source comment reaches nobody", async () => { - registerTool({ - ...claudeConfig, - telemetryLocalRead: { - kind: "declared", - limitation: "read alone: nothing to join on yet.", - supplies: SUPPLIES_NOTHING, - }, - }); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - const claudeReport = result.toolReports.find((r) => r.tool === "claude"); - expect(claudeReport).toMatchObject({ - status: "found", - reason: "read alone: nothing to join on yet.", - }); - }); - - it("invents no limitation for a covered tool that declares none", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - const claudeReport = result.toolReports.find((r) => r.tool === "claude"); - expect(claudeReport && "reason" in claudeReport).toBe(false); - }); - - it("stores a found session's counters in the stored shape, marked as read locally", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - const claudeReport = result.toolReports.find((r) => r.tool === "claude"); - expect(claudeReport).toMatchObject({ status: "found", recordsFound: 1, recordsStored: 1 }); - const [stored] = [...sink.files.values()].flat(); - expect(stored).toMatchObject({ - sink_schema_version: 2, - provenance: "local-read", - tool: "claude", - vendor_id: SESSION_ID, - input_tokens: 10, - output_tokens: 20, - }); - }); - - it("stamps no person field when nobody opted in - the default", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect("person_id" in stored).toBe(false); - expect("person_display_name" in stored).toBe(false); - }); - - it("stamps the CLI's own version on the record it stores, read through the version port", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER, - new FakeCurrentVersion("9.9.9-test") - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.cli_version).toBe("9.9.9-test"); - }); - - it("stamps no cli_version at all when no version reader was given - never a guessed default", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect("cli_version" in stored).toBe(false); - }); - - it("stamps the identifier a person chose, and a display name only once they set one", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - new InMemoryPersonIdentityReader({ personId: "person-1", origin: "minted", alsoMe: [] }), - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.person_id).toBe("person-1"); - expect("person_display_name" in stored).toBe(false); - }); - - it("carries a display name alongside the identifier, never in its place", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - new InMemoryPersonIdentityReader({ - personId: "person-1", - origin: "minted", - alsoMe: [], - displayName: "Baptiste", - }), - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.person_id).toBe("person-1"); - expect(stored.person_display_name).toBe("Baptiste"); - }); - - it("leaves a session stored before opting in unnamed, even on a later read", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const identity = new InMemoryPersonIdentityReader(null); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - identity, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - identity.set({ personId: "person-1", origin: "minted", alsoMe: [] }); - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect("person_id" in stored).toBe(false); - }); - - // `CANDIDATE` carries no `tool` field — the type omits it — so the stamp cannot have come - // from the reader. - it("stamps the tool it asked", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.tool).toBe("claude"); - }); - - // `LocalCostCandidateRecord` omits `tool`, so the assignment below does not compile; - // `@ts-expect-error` inverts that into the assertion — make it settable and `tsc` fails. - it("forbids a reader from naming its own tool, at compile time", () => { - const candidate: LocalCostCandidateRecord = { - ...CANDIDATE, - // @ts-expect-error `tool` is omitted from what a reader may return - tool: "codex", - }; - expect(candidate).toBeDefined(); - }); - - it("leaves the store byte-identical on a second read of the same session", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - const afterFirst = JSON.stringify([...sink.files.values()]); - - const second = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - const afterSecond = JSON.stringify([...sink.files.values()]); - - expect(afterSecond).toBe(afterFirst); - // Still "found", not "empty": the reader returned a record, dedup just skipped it. - const claudeReport = second.toolReports.find((r) => r.tool === "claude"); - expect(claudeReport).toMatchObject({ status: "found", recordsFound: 1, recordsStored: 0 }); - }); - - it("reports a tool with no declared local read as not-covered, with its declared reason", async () => { - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map(), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - const cursor = result.toolReports.find((r) => r.tool === "cursor"); - expect(cursor?.status).toBe("not-covered"); - expect(cursor?.reason).toContain("token count"); - }); - - it("reports an unsupported tool's not-covered reason as exactly what it declared, nothing invented", async () => { - const cursorLocalRead = getAiToolConfig("cursor").telemetryLocalRead; - if (cursorLocalRead.kind !== "unsupported") { - throw new Error("cursor is expected to declare an unsupported local read for this test"); - } - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map(), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - const cursor = result.toolReports.find((r) => r.tool === "cursor"); - expect(cursor?.reason).toBe(cursorLocalRead.reason); - }); - - it("distinguishes not-covered from covered-and-empty", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - const claude = result.toolReports.find((r) => r.tool === "claude"); - expect(claude).toMatchObject({ status: "empty", recordsFound: 0, recordsStored: 0 }); - const cursor = result.toolReports.find((r) => r.tool === "cursor"); - expect(cursor?.status).toBe("not-covered"); - }); - - it("stores what a partial read returns without erroring, when a session is still in progress", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - // A reader mid-transcript returns only the complete records it already parsed — the - // use-case has no way to know, or need to know, that more will exist on a later read. - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await expect( - useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }) - ).resolves.toBeDefined(); - expect([...sink.files.values()].flat()).toHaveLength(1); - }); - - // A requestId appearing in more than one transcript file hands one read both copies as two - // candidates of the same batch, so the index of what is stored has to stay live inside it. - it("stores one line when a single read hands it the same turn twice", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE, { ...CANDIDATE }])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - expect([...sink.files.values()].flat()).toHaveLength(1); - expect(result.toolReports.find((r) => r.tool === "claude")?.recordsStored).toBe(1); - }); - - // `isLocalReadTurnCorrection` still has to fire inside one batch: a live index must not - // turn a strictly improving second candidate into a drop. - it("still lands a correction when the larger reading arrives in the same read", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const larger: LocalCostCandidateRecord = { ...CANDIDATE, output_tokens: 900 }; - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE, larger])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - expect([...sink.files.values()].flat().map((r) => r.output_tokens)).toEqual([20, 900]); - }); - - // The live index keys on a real identifier and nothing else: one shared key for the keyless - // records would match the second against the first and drop it. - it("appends every keyless candidate of one batch, never matching two of them to each other", async () => { - declareClaudeReadable(); - const keyless: LocalCostCandidateRecord = { ...CANDIDATE, turn_id: undefined }; - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([keyless, { ...keyless }])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - expect([...sink.files.values()].flat()).toHaveLength(2); - }); - - it("never synthesises a key for a candidate with no request identifier, and cannot dedup it", async () => { - declareClaudeReadable(); - const noIdCandidate: LocalCostCandidateRecord = { ...CANDIDATE, turn_id: undefined }; - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([noIdCandidate])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - const second = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - // Both reads store it — undeduplicated, as the port's contract accepts for a tool - // with no stable per-record identifier, rather than inventing an unstable one. - expect([...sink.files.values()].flat()).toHaveLength(2); - expect(second.toolReports.find((r) => r.tool === "claude")?.recordsStored).toBe(1); - for (const stored of [...sink.files.values()].flat()) { - expect(stored.turn_id).toBeUndefined(); - } - }); - - // `codex-rollout.ts` flushes the pending turn unconditionally, so a Codex turn read while - // its session runs is stored partial, and its completed reading shares that `turn_id`. - describe("a Codex turn read while it runs is not the last word", () => { - it("lands the completed figures once the rest of the turn arrives", async () => { - const sink = new InMemoryTelemetrySink(); - const reader = growingCodexReader(truncatedCodexFixture(), loadCodexFixture()); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["codex", reader]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const first = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: CODEX_TARGET_ID, - }); - const beforeTurn = [...sink.files.values()] - .flat() - .find((r) => r.turn_id === CODEX_LAST_TURN_ID); - // The partial reading: only the turn's first token_count increment had landed. - expect(beforeTurn).toMatchObject({ cache_read_tokens: 48896, output_tokens: 1401 }); - expect(first.toolReports.find((r) => r.tool === "codex")?.recordsStored).toBe(2); - - const second = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: CODEX_TARGET_ID, - }); - const turnRecords = [...sink.files.values()] - .flat() - .filter((r) => r.turn_id === CODEX_LAST_TURN_ID); - - // The correction landed as a second line — the sink is append-only — carrying the - // complete figures the fixture's own unit test asserts for this turn. - expect(second.toolReports.find((r) => r.tool === "codex")?.recordsStored).toBe(1); - expect(turnRecords).toHaveLength(2); - expect(turnRecords[1]).toMatchObject({ - cache_read_tokens: 99840, - output_tokens: 3550, - input_tokens: 5032, - }); - // The partial reading stays: the sink never edits a stored line, and it is - // `collapseSupersededTurns` in the built report that keeps only the larger of the two. - }); - - it("stops re-appending once a re-read brings nothing new", async () => { - const sink = new InMemoryTelemetrySink(); - const full = loadCodexFixture(); - const reader = growingCodexReader(full, full, full); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["codex", reader]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: CODEX_TARGET_ID }); - const second = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: CODEX_TARGET_ID, - }); - const third = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: CODEX_TARGET_ID, - }); - - // A finished turn read twice (or three times) is counted once, and its figures do - // not change — the same reading offers no improvement over what is already stored. - expect(second.toolReports.find((r) => r.tool === "codex")?.recordsStored).toBe(0); - expect(third.toolReports.find((r) => r.tool === "codex")?.recordsStored).toBe(0); - expect( - [...sink.files.values()].flat().filter((r) => r.turn_id === CODEX_LAST_TURN_ID) - ).toHaveLength(1); - }); - - it("never lets a later, smaller reading of the same turn replace the larger one", async () => { - const sink = new InMemoryTelemetrySink(); - const reader = growingCodexReader(loadCodexFixture(), truncatedCodexFixture()); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["codex", reader]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: CODEX_TARGET_ID }); - const second = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: CODEX_TARGET_ID, - }); - - expect(second.toolReports.find((r) => r.tool === "codex")?.recordsStored).toBe(0); - const turnRecords = [...sink.files.values()] - .flat() - .filter((r) => r.turn_id === CODEX_LAST_TURN_ID); - expect(turnRecords).toHaveLength(1); - expect(turnRecords[0]).toMatchObject({ cache_read_tokens: 99840, output_tokens: 3550 }); - }); - - it("lands the completed figures even once the run journal's own turn_end has been seen", async () => { - // A `turn_end` says only that no *more* growth is coming; a strictly larger candidate is - // itself proof the stored reading was not final, whatever the journal's clock says. - const sink = new InMemoryTelemetrySink(); - const journalReader = new InMemoryRunJournalReader(); - // This turn_end lands after the fixture's last turn opens. - journalReader.set(CODEX_TARGET_ID, journalWithTurnEnd("2026-07-29T15:20:00Z")); - const reader = growingCodexReader(truncatedCodexFixture(), loadCodexFixture()); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["codex", reader]]), - journalReader, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: CODEX_TARGET_ID }); - const second = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: CODEX_TARGET_ID, - }); - - expect(second.toolReports.find((r) => r.tool === "codex")?.recordsStored).toBe(1); - const turnRecords = [...sink.files.values()] - .flat() - .filter((r) => r.turn_id === CODEX_LAST_TURN_ID); - expect(turnRecords).toHaveLength(2); - expect(turnRecords[1]).toMatchObject({ cache_read_tokens: 99840, output_tokens: 3550 }); - }); - }); - - it("never re-appends a kind: 'session' record sharing a turn_id, even once corrections exist for kind: 'request'", async () => { - declareClaudeReadable(); - const sessionCandidate: LocalCostCandidateRecord = { - kind: "session", - vendor_id: SESSION_ID, - vendor_field: "sessionId", - turn_id: "shutdown-1", - turn_field: "id", - cache_read_tokens: 5, - }; - const grownSessionCandidate: LocalCostCandidateRecord = { - ...sessionCandidate, - cache_read_tokens: 7, - }; - const sink = new InMemoryTelemetrySink(); - const reader: SessionCostReader = { - read: async (sessionId: string) => { - if (sessionId !== SESSION_ID) return { records: [], sessionFound: false }; - return { records: [grownSessionCandidate], sessionFound: true }; - }, - }; - await new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([sessionCandidate])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ).execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", reader]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - const second = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - // A `kind: "session"` record is a one-shot cumulative total, never a growing per-turn - // snapshot, so a re-read matching its turn_id is a drop and never a correction. - expect(second.toolReports.find((r) => r.tool === "claude")?.recordsStored).toBe(0); - expect([...sink.files.values()].flat().filter((r) => r.turn_id === "shutdown-1")).toHaveLength( - 1 - ); - }); - - describe("step attribution", () => { - const MOMENT_CANDIDATE: LocalCostCandidateRecord = { - ...CANDIDATE, - event_timestamp: "2026-08-20T10:02:00Z", - }; - - function journalWithOneStep(skill: string): InMemoryRunJournalReader { - const journal = new InMemoryRunJournalReader(); - journal.set(SESSION_ID, { - boundaries: [ - { type: "step_start", at: "2026-08-20T10:00:00Z", skill }, - { type: "turn_end", at: "2026-08-20T10:05:00Z" }, - ], - filesWritten: [], - taskDeclarations: [], - }); - return journal; - } - - it("stores a tool-stated step, marked as stated by the tool", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const candidate: LocalCostCandidateRecord = { ...CANDIDATE, step: "aidd-dev:02-implement" }; - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([candidate])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored).toMatchObject({ - step_attribution: "tool-stated", - step: "aidd-dev:02-implement", - }); - }); - - it("carries a tool-stated plugin alongside its step", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const candidate: LocalCostCandidateRecord = { - ...CANDIDATE, - step: "aidd-dev:02-implement", - step_plugin: "aidd-dev", - }; - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([candidate])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.step_plugin).toBe("aidd-dev"); - }); - - it("derives a step from a journal interval when the tool states none", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([MOMENT_CANDIDATE])]]), - journalWithOneStep("aidd-dev:06-test"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored).toMatchObject({ - step_attribution: "journal-interval", - step: "aidd-dev:06-test", - }); - }); - - it("reads a record as unattributed when neither the tool nor a journal can say", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.step_attribution).toBe("unattributed"); - expect(stored.step).toBeUndefined(); - }); - - // A journal interval covers the same moment too, and still loses: the tool's own answer is - // exact, an interval is only ever an inference. - it("prefers the tool's own stated step over a journal interval that also covers it", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const candidate: LocalCostCandidateRecord = { - ...MOMENT_CANDIDATE, - step: "aidd-dev:02-implement", - }; - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([candidate])]]), - journalWithOneStep("some-other-skill"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored).toMatchObject({ - step_attribution: "tool-stated", - step: "aidd-dev:02-implement", - }); - }); - - // Attribution is an addition, never a precondition: the same transcript, read with and - // without a journal beside it, stores the same figures. - it("yields identical counters whether a journal is present or not", async () => { - declareClaudeReadable(); - const withJournalSink = new InMemoryTelemetrySink(); - const withJournal = new ReadLocalCostUseCase( - withJournalSink, - new Map([["claude", stubReader([MOMENT_CANDIDATE])]]), - journalWithOneStep("aidd-dev:06-test"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - const withoutJournalSink = new InMemoryTelemetrySink(); - const withoutJournal = new ReadLocalCostUseCase( - withoutJournalSink, - new Map([["claude", stubReader([MOMENT_CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await withJournal.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - await withoutJournal.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [withStored] = [...withJournalSink.files.values()].flat(); - const [withoutStored] = [...withoutJournalSink.files.values()].flat(); - const counters = (record: typeof withStored) => ({ - input_tokens: record.input_tokens, - output_tokens: record.output_tokens, - cache_read_tokens: record.cache_read_tokens, - cache_creation_tokens: record.cache_creation_tokens, - }); - expect(counters(withStored)).toEqual(counters(withoutStored)); - expect(withStored.step_attribution).toBe("journal-interval"); - expect(withoutStored.step_attribution).toBe("unattributed"); - }); - }); - - describe("project attribution", () => { - function journalWithProject( - projectId: string | undefined, - projectRemote: string | undefined - ): InMemoryRunJournalReader { - const journal = new InMemoryRunJournalReader(); - journal.set(SESSION_ID, { - session: { - type: "session_start", - at: "2026-08-20T09:59:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: SESSION_ID, - ...(projectId === undefined ? {} : { project_id: projectId }), - ...(projectRemote === undefined ? {} : { project_remote: projectRemote }), - }, - boundaries: [], - filesWritten: [], - taskDeclarations: [], - }); - return journal; - } - - it("prefers the remote, and says so", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - journalWithProject("acme-widgets", "git@github.com:acme/widgets.git"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.project_id).toBe("git@github.com:acme/widgets.git"); - expect(stored.project_field).toBe("project_remote"); - }); - - it("falls back to the directory-name field with no remote, and says so", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - journalWithProject("acme-widgets", undefined), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.project_id).toBe("acme-widgets"); - expect(stored.project_field).toBe("project_id"); - }); - - it("stores no project for a session with no journal at all", async () => { - declareClaudeReadable(); - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const [stored] = [...sink.files.values()].flat(); - expect(stored.project_id).toBeUndefined(); - expect(stored.project_field).toBeUndefined(); - }); - }); -}); - -describe("a reader that fails", () => { - const BOOM = "opencode export s-1 failed: spawnSync opencode ETIMEDOUT"; - - function throwingReader(): SessionCostReader { - return { - read: async () => { - throw new Error(BOOM); - }, - }; - } - - it("costs its own tool's figures and no other tool's", async () => { - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([ - ["opencode", throwingReader()], - ["claude", stubReader([CANDIDATE])], - ]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - const claude = result.toolReports.find((report) => report.tool === "claude"); - expect(claude?.status).toBe("found"); - expect(claude?.recordsStored).toBe(1); - expect([...sink.files.values()].flat()).toHaveLength(1); - }); - - it("says the tool could not be read, and why, in the reader's own words", async () => { - const useCase = new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - new Map([["opencode", throwingReader()]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const opencode = ( - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }) - ).toolReports.find((report) => report.tool === "opencode"); - - expect(opencode?.status).toBe("unreadable"); - expect(opencode?.reason).toBe(BOOM); - }); - - it("is a fifth answer, never one of the four that already exist", async () => { - const useCase = new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - new Map([["opencode", throwingReader()]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const opencode = ( - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }) - ).toolReports.find((report) => report.tool === "opencode"); - - // The four it must not be mistaken for: it billed nothing, it has no trace of the - // session, it cannot be read at all, or it read fine. - expect(["empty", "not-found", "not-covered", "found"]).not.toContain(opencode?.status); - }); - - it("claims no zero when every reader fails", async () => { - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([ - ["opencode", throwingReader()], - ["claude", throwingReader()], - ]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - expect([...sink.files.values()].flat()).toEqual([]); - const failed = result.toolReports.filter((report) => - ["opencode", "claude"].includes(report.tool) - ); - expect(failed.map((report) => report.status)).toEqual(["unreadable", "unreadable"]); - expect(failed.every((report) => report.recordsFound === 0)).toBe(true); - expect(result.toolReports.some((report) => report.status === "empty")).toBe(false); - }); - - it("stores what a failed read missed, once the reader recovers", async () => { - const sink = new InMemoryTelemetrySink(); - const failing = new ReadLocalCostUseCase( - sink, - new Map([["claude", throwingReader()]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - await failing.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }); - - const recovered = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - const result = await recovered.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - expect(result.toolReports.find((report) => report.tool === "claude")?.recordsStored).toBe(1); - }); -}); - -describe("reading every session the journal knows", () => { - function journalNaming(...vendorIds: readonly string[]): InMemoryRunJournalReader { - const reader = new InMemoryRunJournalReader(); - for (const vendorId of vendorIds) { - reader.set(vendorId, { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at: "2026-08-20T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: vendorId, - }, - }); - } - return reader; - } - - function readerFor(records: ReadonlyMap) { - return { - read: async (sessionId: string) => ({ - records: records.get(sessionId) ?? [], - sessionFound: records.has(sessionId), - }), - }; - } - - it("reads every journalled session when none is named", async () => { - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - readerFor( - new Map([ - ["s-a", [{ ...CANDIDATE, vendor_id: "s-a", turn_id: "a" }]], - ["s-b", [{ ...CANDIDATE, vendor_id: "s-b", turn_id: "b" }]], - ]) - ), - ], - ]), - journalNaming("s-a", "s-b"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {} }); - - expect(result.sessions.map((session) => session.sessionId)).toEqual(["s-a", "s-b"]); - expect([...sink.files.values()].flat()).toHaveLength(2); - }); - - it("reads only the session named, when one is", async () => { - const useCase = new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - new Map([["claude", readerFor(new Map([["s-a", [CANDIDATE]]]))]]), - journalNaming("s-a", "s-b"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: "s-a" }); - - expect(result.sessions.map((session) => session.sessionId)).toEqual(["s-a"]); - }); - - it("reads nothing, without failing, when the journal names no session", async () => { - const useCase = new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - expect(await useCase.execute({ projectRoot: PROJECT_ROOT, env: {} })).toEqual({ - sessions: [], - toolReports: expect.anything(), - }); - }); - - it("stores nothing new on a second sweep", async () => { - const sink = new InMemoryTelemetrySink(); - const readers = new Map([ - ["claude", readerFor(new Map([["s-a", [{ ...CANDIDATE, vendor_id: "s-a" }]]]))], - ]); - const useCase = new ReadLocalCostUseCase( - sink, - readers, - journalNaming("s-a"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {} }); - - const second = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {} }); - - expect(second.toolReports.find((report) => report.tool === "claude")?.recordsStored).toBe(0); - expect([...sink.files.values()].flat()).toHaveLength(1); - }); - - it("keeps reading the other sessions when one session's reader throws", async () => { - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async (sessionId: string) => { - if (sessionId === "s-bad") throw new Error("that one is broken"); - return { records: [{ ...CANDIDATE, vendor_id: sessionId }], sessionFound: true }; - }, - }, - ], - ]), - journalNaming("s-bad", "s-good"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {} }); - - const bad = result.sessions.find((session) => session.sessionId === "s-bad"); - const good = result.sessions.find((session) => session.sessionId === "s-good"); - expect(bad?.toolReports.find((report) => report.tool === "claude")?.status).toBe("unreadable"); - expect(good?.toolReports.find((report) => report.tool === "claude")?.status).toBe("found"); - expect([...sink.files.values()].flat()).toHaveLength(1); - }); - - it("sums a tool's counts across the sweep and keeps its strongest answer", async () => { - const useCase = new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - new Map([ - [ - "claude", - readerFor( - new Map([ - ["s-a", [{ ...CANDIDATE, vendor_id: "s-a", turn_id: "a" }]], - ["s-b", []], - ]) - ), - ], - ]), - journalNaming("s-a", "s-b"), - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const claude = (await useCase.execute({ projectRoot: PROJECT_ROOT, env: {} })).toolReports.find( - (report) => report.tool === "claude" - ); - - // It read one session and found nothing in the other. Reporting it as empty would - // discard a real figure; reporting it as found is what actually happened. - expect(claude?.status).toBe("found"); - expect(claude?.recordsFound).toBe(1); - }); -}); - -describe("a failure in a sweep does not disappear behind a success", () => { - it("reports the tool as read, and still says how many sessions it could not read", async () => { - // Nineteen good sessions and one bad is the case that matters: the figures are real, so a - // failure visible only in the status would vanish exactly where there is most to lose. - const journal = new InMemoryRunJournalReader(); - for (const vendorId of ["s-good", "s-bad"]) { - journal.set(vendorId, { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at: "2026-08-20T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: vendorId, - }, - }); - } - const useCase = new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - new Map([ - [ - "claude", - { - read: async (sessionId: string) => { - if (sessionId === "s-bad") throw new Error("that one is broken"); - return { records: [{ ...CANDIDATE, vendor_id: sessionId }], sessionFound: true }; - }, - }, - ], - ]), - journal, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const claude = (await useCase.execute({ projectRoot: PROJECT_ROOT, env: {} })).toolReports.find( - (report) => report.tool === "claude" - ); - - expect(claude?.status).toBe("found"); - expect(claude?.recordsFound).toBe(1); - expect(claude?.sessionsFailed).toBe(1); - expect(claude?.failureReason).toBe("that one is broken"); - }); - - it("counts no failure when every session read cleanly", async () => { - const useCase = new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - - const claude = ( - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SESSION_ID }) - ).toolReports.find((report) => report.tool === "claude"); - - expect(claude?.sessionsFailed).toBe(0); - expect(claude?.failureReason).toBeUndefined(); - }); - - // `read` is the only thing that writes a day file, so it is the only thing that can bound - // how many there are. - it("prunes day files outside the retention window, once per sweep", async () => { - const sink = new InMemoryTelemetrySink(); - for (const day of ["2026-01-01", "2026-01-02", "2026-01-03"]) { - await sink.appendRecord( - { - ...CANDIDATE, - sink_schema_version: 2, - provenance: "local-read", - tool: "claude", - step_attribution: "unattributed", - } as TelemetrySinkRecord, - new Date(`${day}T00:00:00Z`) - ); - } - - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER, - undefined, - undefined, - 2 - ); - await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - at: new Date("2026-01-03T12:00:00Z"), - }); - - expect(sink.deletedFiles).toEqual(["2026-01-01.jsonl"]); - }); - - // Housekeeping must never cost the figures the sweep just stored. - it("reports its figures even when a day file cannot be deleted", async () => { - const sink = new InMemoryTelemetrySink(); - for (const day of ["2026-01-01", "2026-01-02"]) { - await sink.appendRecord( - { - ...CANDIDATE, - sink_schema_version: 2, - provenance: "local-read", - tool: "claude", - step_attribution: "unattributed", - } as TelemetrySinkRecord, - new Date(`${day}T00:00:00Z`) - ); - } - sink.undeletable.add("2026-01-01.jsonl"); - - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER, - undefined, - undefined, - 1 - ); - - await expect( - useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, at: new Date("2026-01-02T12:00:00Z") }) - ).resolves.toBeDefined(); - }); -}); - -// This is the one remaining writer of the sink, so a refusal that does not hold here is -// cosmetic. -describe("a refusal holds on the one writer left", () => { - it("reads nothing and stores nothing when the project switch is off", async () => { - const sink = new InMemoryTelemetrySink(); - const journal = new InMemoryRunJournalReader(); - journal.set(SESSION_ID, { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at: "2026-08-20T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: SESSION_ID, - }, - }); - const refused = new StubTelemetryEvidenceReader(); - refused.enabled = false; - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - journal, - NULL_PERSON_IDENTITY_READER, - refused - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, env: {} }); - - expect(result.sessions).toHaveLength(0); - expect(result.refusedReason).toBeDefined(); - expect([...sink.files.values()].flat()).toHaveLength(0); - }); - - it("refuses even a direct --session read, which never touches the journal", async () => { - const sink = new InMemoryTelemetrySink(); - const refused = new StubTelemetryEvidenceReader(); - refused.enabled = false; - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader([CANDIDATE])]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - refused - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SESSION_ID, - }); - - expect(result.sessions).toHaveLength(0); - expect([...sink.files.values()].flat()).toHaveLength(0); - }); -}); - -/** Which readers a session actually reaches. Reading every tool costs real time: the - * OpenCode reader shells out and waits 1.15s for a session it does not have. */ -describe("which readers a session reaches", () => { - const SPIED_SESSION = "spied-session"; - const SUPPLIES = { - tokenCounters: true, - amount: false, - toolStatedStep: false, - agentName: false, - } as const; - - let claudeConfig: AiTool; - let codexConfig: AiTool; - - beforeEach(() => { - claudeConfig = getAiToolConfig("claude"); - codexConfig = getAiToolConfig("codex"); - registerTool({ ...claudeConfig, telemetryLocalRead: { kind: "declared", supplies: SUPPLIES } }); - registerTool({ ...codexConfig, telemetryLocalRead: { kind: "declared", supplies: SUPPLIES } }); - }); - - afterEach(() => { - registerTool(claudeConfig); - registerTool(codexConfig); - }); - - /** Records every session id it was asked about, so a test can assert on a reader that was - * never reached rather than only on the answer it would have given. */ - function spyReader(): SessionCostReader & { readonly asked: string[] } { - const asked: string[] = []; - return { - asked, - read: async (sessionId: string) => { - asked.push(sessionId); - return { records: [], sessionFound: true }; - }, - }; - } - - function journalNaming(host: string): RunJournal { - return { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at: "2026-03-02T08:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: host, - vendor_id: SPIED_SESSION, - }, - }; - } - - function useCaseWith( - journalReader: typeof NULL_RUN_JOURNAL_READER, - readers: ReadonlyMap - ): ReadLocalCostUseCase { - return new ReadLocalCostUseCase( - new InMemoryTelemetrySink(), - readers, - journalReader, - NULL_PERSON_IDENTITY_READER, - TELEMETRY_EVIDENCE_READER - ); - } - - it("asks only the reader the journal named, and says so about the ones it skipped", async () => { - const journals = new InMemoryRunJournalReader(); - journals.set(SPIED_SESSION, journalNaming("claude-code")); - const claude = spyReader(); - const codex = spyReader(); - const useCase = useCaseWith( - journals, - new Map([ - ["claude", claude], - ["codex", codex], - ]) - ); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SPIED_SESSION, - }); - - expect(claude.asked).toEqual([SPIED_SESSION]); - expect(codex.asked).toEqual([]); - expect(result.toolReports.find((r) => r.tool === "codex")?.status).toBe("not-asked"); - }); - - it("never reports a skipped reader as having found no session", async () => { - const journals = new InMemoryRunJournalReader(); - journals.set(SPIED_SESSION, journalNaming("claude-code")); - const useCase = useCaseWith(journals, new Map([["claude", spyReader()]])); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SPIED_SESSION, - }); - - // "no session found" is an observation: this tool was looked in and held nothing. - // Nothing was looked in here, so nothing may claim it was. - expect(result.toolReports.find((r) => r.tool === "codex")?.status).not.toBe("not-found"); - }); - - it("still names a tool nothing can read, with its own declared reason, whoever the session belongs to", async () => { - const journals = new InMemoryRunJournalReader(); - journals.set(SPIED_SESSION, journalNaming("claude-code")); - const useCase = useCaseWith(journals, new Map([["claude", spyReader()]])); - - const result = await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: SPIED_SESSION, - }); - - // Cursor's declaration says its files carry no counter at all: a fact about the tool, true - // of every session, and stronger than "not this session's tool". - const cursor = result.toolReports.find((r) => r.tool === "cursor"); - expect(cursor?.status).toBe("not-covered"); - expect(cursor?.reason).toBeTruthy(); - }); - - it("asks every reader when no journal names a tool, since then none is ruled out", async () => { - const claude = spyReader(); - const codex = spyReader(); - const useCase = useCaseWith( - NULL_RUN_JOURNAL_READER, - new Map([ - ["claude", claude], - ["codex", codex], - ]) - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SPIED_SESSION }); - - expect(claude.asked).toEqual([SPIED_SESSION]); - expect(codex.asked).toEqual([SPIED_SESSION]); - }); - - it("asks every reader when the journal names a host no tool claims", async () => { - const journals = new InMemoryRunJournalReader(); - journals.set(SPIED_SESSION, journalNaming("a-tool-nobody-registered")); - const claude = spyReader(); - const codex = spyReader(); - const useCase = useCaseWith( - journals, - new Map([ - ["claude", claude], - ["codex", codex], - ]) - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, env: {}, sessionId: SPIED_SESSION }); - - expect(claude.asked).toEqual([SPIED_SESSION]); - expect(codex.asked).toEqual([SPIED_SESSION]); - }); -}); diff --git a/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts deleted file mode 100644 index 9204a849e..000000000 --- a/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts +++ /dev/null @@ -1,1181 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { beforeEach, describe, expect, it } from "vitest"; -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; -import { ReportCostUseCase } from "../../../../src/contexts/telemetry/application/report-cost-use-case.js"; -import { toMicroUsd } from "../../../../src/contexts/telemetry/domain/cost-report.js"; -import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import type { LocalCostCandidateRecord } from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; -import { taskFolderPathFromIdentity } from "../../../../src/contexts/telemetry/domain/task-backlog-link.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { UnreadableIdentityFileError } from "../../../../src/kernel/errors.js"; -import { AI_TOOL_IDS } from "../../../../src/kernel/tool.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; -import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; -import { InMemoryRunJournalReader } from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTaskBacklogReader } from "../../../helpers/ports/in-memory-task-backlog-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -const PERIOD = { fromDay: "2026-08-17", toDay: "2026-08-21" } as const; -// Every test here is about what the sink and the journal hold, not the project switch: the -// fixed root and empty env only satisfy `execute()`'s shape. -const BASE_OPTIONS = { projectRoot: "/project", env: {} } as const; -const STORED_ON = new Date("2026-08-21T09:00:00Z"); -const TASK = "2026_08/2026_08_21_cost-reporter"; - -function record(overrides: Partial): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - event_timestamp: "2026-08-18T10:00:00.000Z", - ...overrides, - }; -} - -describe("ReportCostUseCase", () => { - let sink: InMemoryTelemetrySink; - let journals: InMemoryRunJournalReader; - let identity: InMemoryPersonIdentityStore; - let evidence: StubTelemetryEvidenceReader; - let taskBacklog: InMemoryTaskBacklogReader; - let useCase: ReportCostUseCase; - - beforeEach(() => { - sink = new InMemoryTelemetrySink(); - journals = new InMemoryRunJournalReader(); - identity = new InMemoryPersonIdentityStore(); - evidence = new StubTelemetryEvidenceReader(); - taskBacklog = new InMemoryTaskBacklogReader(); - useCase = new ReportCostUseCase( - sink, - journals, - identity, - evidence, - taskBacklog, - new CapturingLogger() - ); - }); - - async function store(...records: readonly TelemetrySinkRecord[]): Promise { - for (const stored of records) await sink.appendRecord(stored, STORED_ON); - } - - it("reports a period from what the sink holds, whatever session it belongs to", async () => { - await store( - record({ vendor_id: "s-1", cost_usd: 0.1 }), - record({ vendor_id: "s-2", cost_usd: 0.2 }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.sessions).toBe(2); - expect(built.totals.costMicroUsd).toBe(toMicroUsd(0.3)); - expect([built.fromDay, built.toDay]).toEqual(["2026-08-17", "2026-08-21"]); - }); - - it("leaves out work that happened before the period, however recently it was stored", async () => { - // Both lines are appended on the same day; only their own moments differ. - await store( - record({ vendor_id: "july", cost_usd: 9, event_timestamp: "2026-07-29T15:12:27.889Z" }), - record({ vendor_id: "august", cost_usd: 1 }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.costMicroUsd).toBe(toMicroUsd(1)); - expect(built.sessions).toBe(1); - }); - - it("restricts to the sessions that wrote into the task asked for", async () => { - journals.set("s-task", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: "s-task", - }, - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:30:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [], - }); - await store( - record({ vendor_id: "s-task", cost_usd: 1 }), - record({ vendor_id: "s-elsewhere", cost_usd: 8 }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD, task: TASK }); - - expect(built.task).toBe(TASK); - expect(built.totals.costMicroUsd).toBe(toMicroUsd(1)); - }); - - // The second record predates the journal's own span, so no written-file inference can - // reach it however many files that session went on to write. - it("names a record inside the journal's span after the only task folder that session wrote into", async () => { - journals.set("s-inferred", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: "s-inferred", - }, - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:30:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [], - }); - await store( - record({ - vendor_id: "s-inferred", - cost_usd: 1, - event_timestamp: "2026-08-18T09:15:00Z", - }), - record({ - vendor_id: "s-inferred", - cost_usd: 2, - event_timestamp: "2026-08-17T09:15:00Z", - }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - const inferred = built.byTasks.find((row) => row.attribution === "inferred"); - expect(inferred?.task).toBe(TASK); - expect(inferred?.totals.costMicroUsd).toBe(toMicroUsd(1)); - expect(built.byTasks.some((row) => row.reason !== undefined)).toBe(true); - }); - - it("gives every declared tool a row, with the reason an unreadable one cannot be read", async () => { - await store(record({ cost_usd: 1 })); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.byTools.map((row) => row.tool)).toEqual([...AI_TOOL_IDS]); - const cursor = built.byTools.find((row) => row.tool === "cursor"); - expect(cursor?.coverage).toBe("not-covered"); - expect(cursor?.reason).toBeTruthy(); - }); - - it("reports what the read could not place or could not parse", async () => { - await store( - record({ cost_usd: 1 }), - record({ vendor_id: "no-moment", event_timestamp: undefined }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.undatedRecords).toBe(1); - expect(built.totals.requests).toBe(1); - }); - - it("answers an empty period with an empty report and no error", async () => { - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.sessions).toBe(0); - expect(built.totals).toEqual({ requests: 0 }); - expect(built.byTools.every((row) => row.totals.requests === 0)).toBe(true); - }); - - it("reports a period whose sessions have no journal at all", async () => { - await store(record({ cost_usd: 1 })); - - expect((await useCase.execute({ ...BASE_OPTIONS, period: PERIOD })).totals.requests).toBe(1); - }); - - it("resolves byPeople against the identity this store holds", async () => { - identity = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "adopted", - alsoMe: ["machine-1"], - }); - useCase = new ReportCostUseCase( - sink, - journals, - identity, - evidence, - new InMemoryTaskBacklogReader(), - new CapturingLogger() - ); - await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - const mapped = built.byPeople.find((row) => row.resolution === "mapped"); - expect(mapped?.person).toBe("person-a"); - }); - - it("survives an identity that cannot be read, reporting every figure with the caveat set", async () => { - identity.throwOnRead = new UnreadableIdentityFileError(identity.filePath, "EISDIR"); - await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(1); - expect(built.identityUnusableCause).toBe("unreadable"); - expect(built.byPeople.every((row) => row.resolution !== "mapped")).toBe(true); - }); - - it("reports no identity declared as its own cause, distinct from unreadable", async () => { - await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.identityUnusableCause).toBe("absent"); - }); - - it("reports whether the project switch is on, from the evidence reader alone", async () => { - evidence.enabled = false; - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.measurementEnabled).toBe(false); - }); - - it("reports the switch as on when the evidence reader says so, even with nothing measured", async () => { - evidence.enabled = true; - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.measurementEnabled).toBe(true); - expect(built.totals.requests).toBe(0); - }); - - it("re-throws an error it does not recognise rather than mislabelling it as a named cause", async () => { - identity.throwOnRead = new Error("some other failure entirely"); - - await expect(useCase.execute({ ...BASE_OPTIONS, period: PERIOD })).rejects.toThrow( - "some other failure entirely" - ); - }); - - // A journal moment is second-precision — the writing hook strips the milliseconds — while - // a record keeps them, so a record landing in that same second still counts as inside. - it("counts a record inside the last second its journal wrote as witnessed", async () => { - journals.set("s-same-second", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FB0", - tool: "claude-code", - vendor_id: "s-same-second", - }, - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:30:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [], - }); - await store( - record({ - vendor_id: "s-same-second", - cost_usd: 5, - event_timestamp: "2026-08-18T09:30:00.351Z", - }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.byTasks.find((row) => row.attribution === "inferred")?.task).toBe(TASK); - }); - - it("resolves the backlog declaration of a task no interval ever declared", async () => { - journals.set("s-written-only", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAX", - tool: "claude-code", - vendor_id: "s-written-only", - }, - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:40:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [], - }); - taskBacklog.set(taskFolderPathFromIdentity(TASK), { - kind: "declared", - link: { backlog: "acme/widgets#742", writtenAt: "2026-08-18T09:00:00Z", writtenBy: "x" }, - }); - await store( - record({ - vendor_id: "s-written-only", - cost_usd: 3, - event_timestamp: "2026-08-18T09:20:00Z", - }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.byBacklog.map((row) => row.backlog)).toContain("acme/widgets#742"); - }); - - it("resolves the declaration through TaskBacklogReader, keyed on the folder the task identity resolves to", async () => { - // The double is set on the exact folder path a real adapter would be asked to read, - // never on the bare task identity string. - journals.set("s-task", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAW", - tool: "claude-code", - vendor_id: "s-task", - }, - // A witnessed moment after the record's own timestamp: without one the declared - // interval's end collapses to its start and the record below falls outside it. - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:40:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [ - { - type: "task_declared", - at: "2026-08-18T09:00:00Z", - path: `aidd_docs/tasks/${TASK}/spec.md`, - }, - ], - }); - taskBacklog.set(taskFolderPathFromIdentity(TASK), { - kind: "declared", - link: { - backlog: "acme/widgets#661", - writtenAt: "2026-08-18T08:00:00Z", - writtenBy: "aidd-pm:04-spec", - }, - }); - await store( - record({ vendor_id: "s-task", cost_usd: 4, event_timestamp: "2026-08-18T09:30:00Z" }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - const named = built.byBacklog.find((row) => row.backlog === "acme/widgets#661"); - expect(named?.totals.requests).toBe(1); - expect(named?.totals.costMicroUsd).toBe(toMicroUsd(4)); - }); - - it("names no tool, by string literal", () => { - const source = readFileSync( - fileURLToPath( - new URL( - "../../../../src/contexts/telemetry/application/report-cost-use-case.ts", - import.meta.url - ) - ), - "utf8" - ); - - for (const toolId of AI_TOOL_IDS) { - expect(source).not.toContain(`"${toolId}"`); - expect(source).not.toContain(`'${toolId}'`); - } - }); -}); - -describe("a report that catches the sink up first", () => { - const SESSION = "s-catch-up"; - const AT = "2026-08-18T10:00:00.000Z"; - - let sink: InMemoryTelemetrySink; - let journals: InMemoryRunJournalReader; - let evidence: StubTelemetryEvidenceReader; - - function journalAt(at: string): RunJournal { - return { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at, - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: SESSION, - }, - }; - } - - /** The real local read over in-memory ports, never a double: a stand-in could be made to - * show `report` reaching the read whether or not it does. */ - function localRead(records: readonly LocalCostCandidateRecord[]): ReadLocalCostUseCase { - return new ReadLocalCostUseCase( - sink, - new Map([["claude", { read: async () => ({ records, sessionFound: true }) }]]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - } - - function reportWith(read?: ReadLocalCostUseCase): ReportCostUseCase { - return new ReportCostUseCase( - sink, - journals, - new InMemoryPersonIdentityStore(), - evidence, - new InMemoryTaskBacklogReader(), - new CapturingLogger(), - read - ); - } - - const CANDIDATE: LocalCostCandidateRecord = { - kind: "request", - vendor_id: SESSION, - vendor_field: "sessionId", - turn_id: "t-1", - event_timestamp: AT, - input_tokens: 100, - output_tokens: 10, - }; - - beforeEach(() => { - sink = new InMemoryTelemetrySink(); - journals = new InMemoryRunJournalReader(); - evidence = new StubTelemetryEvidenceReader(); - }); - - it("reports a journalled session nobody ran a read for", async () => { - journals.set(SESSION, journalAt(AT)); - - const built = await reportWith(localRead([CANDIDATE])).execute({ - ...BASE_OPTIONS, - period: PERIOD, - }); - - expect(built.totals.requests).toBe(1); - expect(built.totals.inputTokens).toBe(100); - }); - - it("reports only what the sink holds when no read was wired, rather than guessing", async () => { - journals.set(SESSION, journalAt(AT)); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(0); - }); - - // A journal can disappear while its records stay: `aidd_docs/runs/` is git-ignored, so a - // clean checkout holds the figures and none of the boundaries. - it("keeps a stored step for a session the period's journals say nothing about", async () => { - await sink.appendRecord( - record({ - vendor_id: "s-no-journal", - event_timestamp: "2026-08-18T10:00:00.000Z", - step_attribution: "journal-interval", - step: "aidd-dev:05-review", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "journal-interval", step: "aidd-dev:05-review" }) - ); - }); - - it("leaves a tool-stated step alone, even where the journal's interval names another", async () => { - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - boundaries: [ - { type: "step_start", at: "2026-08-18T09:30:00Z", skill: "aidd-dev:02-implement" }, - ], - }); - await sink.appendRecord( - record({ - vendor_id: SESSION, - event_timestamp: "2026-08-18T10:00:00.000Z", - step_attribution: "tool-stated", - step: "aidd-vcs:01-commit", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "tool-stated", step: "aidd-vcs:01-commit" }) - ); - }); - - // The record's moment falls outside every interval, so only the prompt both sides name can - // attribute it; three steps really do open under one prompt on a measured live session. - it("names the step a shared prompt opened first, never the last to reuse it", async () => { - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - boundaries: [ - { - type: "step_start", - at: "2026-08-18T11:00:00Z", - skill: "aidd-pm:04-spec", - turn_id: "p-abc", - }, - { - type: "step_start", - at: "2026-08-18T11:30:00Z", - skill: "aidd-dev:01-plan", - turn_id: "p-abc", - }, - ], - }); - await sink.appendRecord( - record({ - vendor_id: SESSION, - event_timestamp: "2026-08-18T10:00:00.000Z", - prompt_id: "p-abc", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "prompt-matched", step: "aidd-pm:04-spec" }) - ); - }); - - it("attributes on the prompt both sides name, where no interval covers the moment", async () => { - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - boundaries: [ - { - type: "step_start", - at: "2026-08-18T11:00:00Z", - skill: "aidd-dev:02-implement", - turn_id: "p-abc", - }, - ], - }); - await sink.appendRecord( - record({ - vendor_id: SESSION, - // Before the step ever opened: no interval can reach it. - event_timestamp: "2026-08-18T10:00:00.000Z", - prompt_id: "p-abc", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "prompt-matched", step: "aidd-dev:02-implement" }) - ); - }); - - /** The journal never opened the step because the hook was not installed when the session - * ran; the record still carries the skill its own transcript named for that prompt. */ - it("attributes on the skill the record's own prompt invoked, where no journal saw it", async () => { - journals.set(SESSION, journalAt("2026-08-18T09:00:00Z")); - await sink.appendRecord( - record({ - vendor_id: SESSION, - event_timestamp: "2026-08-18T10:00:00.000Z", - prompt_id: "p-abc", - prompt_skill: "aidd-dev:01-plan", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "prompt-matched", step: "aidd-dev:01-plan" }) - ); - }); - - // The journal was written by a hook the host itself fired while the transcript is read back - // afterwards, so the reading with a witness wins. - it("keeps the journal's own answer when both sides name a skill for the same prompt", async () => { - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - boundaries: [ - { - type: "step_start", - at: "2026-08-18T11:00:00Z", - skill: "aidd-pm:04-spec", - turn_id: "p-abc", - }, - ], - }); - await sink.appendRecord( - record({ - vendor_id: SESSION, - event_timestamp: "2026-08-18T10:00:00.000Z", - prompt_id: "p-abc", - prompt_skill: "aidd-dev:01-plan", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "prompt-matched", step: "aidd-pm:04-spec" }) - ); - }); - - it("derives a stored record's step from the journal rather than trusting the stored one", async () => { - const at = "2026-08-18T10:00:00.000Z"; - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - // The pause after the record is what the step is capped at: a step runs past a pause - // but never past the last moment its own journal witnessed. - boundaries: [ - { type: "step_start", at: "2026-08-18T09:30:00Z", skill: "aidd-dev:02-implement" }, - { type: "turn_end", at: "2026-08-18T10:30:00Z" }, - ], - }); - await sink.appendRecord( - record({ vendor_id: SESSION, event_timestamp: at, step_attribution: "unattributed" }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "journal-interval", step: "aidd-dev:02-implement" }) - ); - }); - - // `groupByTurnId` indexes nothing without a `turn_id`, so re-reading a session whose - // records carry none would append them a second time. - it("leaves a session alone when its stored records carry no turn id to match on", async () => { - journals.set(SESSION, journalAt(AT)); - await sink.appendRecord(record({ vendor_id: SESSION, event_timestamp: AT }), STORED_ON); - let reads = 0; - const counting = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - reads += 1; - return { records: [CANDIDATE], sessionFound: true }; - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - - await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(reads).toBe(0); - }); - - it("reads a stored session again, so a live session's later turns land", async () => { - journals.set(SESSION, journalAt(AT)); - // A `turn_id` is what makes a re-read reconcilable rather than duplicating. - await sink.appendRecord( - record({ vendor_id: SESSION, event_timestamp: AT, turn_id: "req_1" }), - STORED_ON - ); - let reads = 0; - const counting = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - reads += 1; - return { records: [CANDIDATE], sessionFound: true }; - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - - await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(reads).toBe(1); - }); - - it("reaches a session journalled on the last day of the period, which runs to midnight", async () => { - // The bound is the first instant after `toDay`, not `toDay` at 00:00: getting it wrong - // drops the whole last day, which `--days N` always makes today. - journals.set(SESSION, journalAt(`${PERIOD.toDay}T23:59:59.999Z`)); - - const built = await reportWith( - localRead([{ ...CANDIDATE, event_timestamp: `${PERIOD.toDay}T23:59:59.999Z` }]) - ).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(1); - }); - - it("reaches a session journalled at the very first instant of the period", async () => { - journals.set(SESSION, journalAt(`${PERIOD.fromDay}T00:00:00.000Z`)); - - const built = await reportWith( - localRead([{ ...CANDIDATE, event_timestamp: `${PERIOD.fromDay}T00:00:00.000Z` }]) - ).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(1); - }); - - it("never reaches for a session journalled the instant the period ends", async () => { - // Midnight opening the day after `toDay` is the first moment outside, not the last one - // inside — the half a `>` instead of a `>=` would silently widen. - const dayAfter = new Date(Date.parse(`${PERIOD.toDay}T00:00:00Z`) + 86_400_000); - journals.set(SESSION, journalAt(dayAfter.toISOString())); - - const built = await reportWith(localRead([CANDIDATE])).execute({ - ...BASE_OPTIONS, - period: PERIOD, - }); - - expect(built.totals.requests).toBe(0); - }); - - it("never reaches for a session whose own moment falls outside the period asked about", async () => { - // Otherwise catching up would cost with the age of the repository rather than with the - // length of the period asked about. - journals.set(SESSION, journalAt("2020-01-01T00:00:00.000Z")); - - const built = await reportWith(localRead([CANDIDATE])).execute({ - ...BASE_OPTIONS, - period: PERIOD, - }); - - expect(built.totals.requests).toBe(0); - }); - - it("deletes no stored day file, since a question is not housekeeping", async () => { - // `read` prunes past its retention window, which is housekeeping; behind a report that - // would delete measurement as a side effect of being asked a question. - journals.set(SESSION, journalAt(AT)); - for (let day = 1; day <= 120; day += 1) { - const stamp = new Date(Date.UTC(2025, 0, day)); - await sink.appendRecord(record({ vendor_id: `old-${day}` }), stamp); - } - const before = await sink.listDayFiles(); - - await reportWith(localRead([CANDIDATE])).execute({ ...BASE_OPTIONS, period: PERIOD }); - - // Asserted as "none of these is gone", not as an unchanged count: the catch-up stores - // what it reads, so it adds a day file of its own. - const after = new Set(await sink.listDayFiles()); - expect(before.filter((file) => !after.has(file))).toEqual([]); - }); - - it("says what a reader could not answer, rather than reporting the silence as no spend", async () => { - // A period where every reader threw would otherwise print exactly what a period with no - // spend prints, and nobody sees the read surface's own output behind a report. - journals.set(SESSION, journalAt(AT)); - const warnings: string[] = []; - const throwing = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - throw new Error("the transcript directory is unreadable"); - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - const report = new ReportCostUseCase( - sink, - journals, - new InMemoryPersonIdentityStore(), - evidence, - new InMemoryTaskBacklogReader(), - { debug: () => {}, info: () => {}, warn: (m: string) => warnings.push(m) }, - throwing - ); - - const built = await report.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(0); - expect(warnings.join("\n")).toContain("the transcript directory is unreadable"); - }); - - it("skips a journal whose own moment cannot be read at all, rather than treating it as now", async () => { - journals.set(SESSION, journalAt("not a moment")); - - const built = await reportWith(localRead([CANDIDATE])).execute({ - ...BASE_OPTIONS, - period: PERIOD, - }); - - expect(built.totals.requests).toBe(0); - }); - - it("opens no tool's files at all when the project switch is off", async () => { - // Asserted on whether the reader was reached, not on the total: a refused catch-up and - // an absent one both report zero, so a total cannot tell them apart. - journals.set(SESSION, journalAt(AT)); - let reads = 0; - const counting = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - reads += 1; - return { records: [CANDIDATE], sessionFound: true }; - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - - evidence.enabled = true; - await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); - const readWhenOn = reads; - - sink = new InMemoryTelemetrySink(); - reads = 0; - evidence.enabled = false; - await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(readWhenOn).toBeGreaterThan(0); - expect(reads).toBe(0); - }); -}); - -class RecordingTaskBacklogReader extends InMemoryTaskBacklogReader { - readonly asked: string[] = []; - - override async read(taskFolderPath: string) { - this.asked.push(taskFolderPath); - return super.read(taskFolderPath); - } -} - -describe("ReportCostUseCase — what it assembles for the report", () => { - const SESSION_AT = "2026-08-18T09:00:00Z"; - const NO_CAPABILITY_SUPPLY = { tokenCounters: true, amount: false } as const; - - let sink: InMemoryTelemetrySink; - let journals: InMemoryRunJournalReader; - let taskBacklog: RecordingTaskBacklogReader; - let logger: CapturingLogger; - - beforeEach(() => { - sink = new InMemoryTelemetrySink(); - journals = new InMemoryRunJournalReader(); - taskBacklog = new RecordingTaskBacklogReader(); - logger = new CapturingLogger(); - }); - - function report(read?: ReadLocalCostUseCase): ReportCostUseCase { - return new ReportCostUseCase( - sink, - journals, - new InMemoryPersonIdentityStore(), - new StubTelemetryEvidenceReader(), - taskBacklog, - logger, - read - ); - } - - function sessionJournal(vendorId: string, lines: Partial): RunJournal { - return { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at: SESSION_AT, - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: vendorId, - }, - ...lines, - }; - } - - it("declares every tool exactly as its profile does, limitation and refusal included", async () => { - const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); - - const declarations = built.byTools.map(({ totals: _totals, ...declaration }) => declaration); - expect(declarations).toStrictEqual([ - { - tool: "claude", - coverage: "covered", - capability: { - localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: true, agentName: true }, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - { - tool: "cursor", - coverage: "not-covered", - reason: "It writes no token count in any file it produces.", - capability: { - localRead: null, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - { - tool: "copilot", - coverage: "covered", - reason: - "Its own file names outputTokens per turn, but session.shutdown carries all four " + - "counters for the whole session — a session total, never a sum of requests. Its four " + - "counters are measured disjoint, cached prompt included.", - capability: { - localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - { - tool: "opencode", - coverage: "covered", - reason: - "Its four counters are measured disjoint for the anthropic provider and for one " + - "OpenAI-compatible provider whose cache was exercised — not confirmed for a " + - "provider that reports prompt tokens inclusive of the cached ones, which none " + - "captured here does.", - capability: { - localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - { - tool: "kilo", - coverage: "not-covered", - reason: "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", - capability: { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, - }, - }, - { - tool: "codex", - coverage: "covered", - capability: { - localRead: { ...NO_CAPABILITY_SUPPLY, toolStatedStep: false, agentName: false }, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - ]); - }); - - it("hands the generic filters to the report, which narrows to the value asked for", async () => { - await sink.appendRecord(record({ vendor_id: "s-1", model: "opus" }), STORED_ON); - await sink.appendRecord(record({ vendor_id: "s-2", model: "sonnet" }), STORED_ON); - - const built = await report().execute({ - ...BASE_OPTIONS, - period: PERIOD, - filters: { model: "opus" }, - }); - - expect(built.filters).toStrictEqual({ model: "opus" }); - expect(built.totals).toStrictEqual({ requests: 1 }); - }); - - it("reports through a journal whose session_start line is torn away", async () => { - journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); - await sink.appendRecord(record({ vendor_id: "s-1" }), STORED_ON); - - const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals).toStrictEqual({ requests: 1 }); - }); - - it("catches up past a journal whose session_start line is torn away", async () => { - journals.set("torn", { boundaries: [], filesWritten: [], taskDeclarations: [] }); - const read = new ReadLocalCostUseCase( - sink, - new Map([["claude", { read: async () => ({ records: [], sessionFound: false }) }]]), - journals, - NULL_PERSON_IDENTITY_READER, - new StubTelemetryEvidenceReader() - ); - - const built = await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals).toStrictEqual({ requests: 0 }); - }); - - it("witnesses a moment through the session_start line alone when no other line is dated", async () => { - journals.set( - "s-1", - sessionJournal("s-1", { - filesWritten: [ - { type: "file_written", at: "not a moment", path: `aidd_docs/tasks/${TASK}/plan.md` }, - ], - }) - ); - await sink.appendRecord( - record({ vendor_id: "s-1", event_timestamp: "2026-08-18T09:00:00.500Z" }), - STORED_ON - ); - - const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.byTasks).toStrictEqual([ - { task: TASK, attribution: "inferred", totals: { requests: 1 } }, - ]); - }); - - it("witnesses nothing from a journal whose every line is undated, rather than everything", async () => { - journals.set( - "s-1", - sessionJournal("s-1", { - session: { - type: "session_start", - at: "not a moment", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: "s-1", - }, - }) - ); - await sink.appendRecord(record({ vendor_id: "s-1" }), STORED_ON); - - const built = await report().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.byTasks).toStrictEqual([{ reason: "no-declaration", totals: { requests: 1 } }]); - }); - - it("asks the backlog once per task folder, never per written file and never for a path outside a task", async () => { - journals.set( - "s-1", - sessionJournal("s-1", { - filesWritten: [ - { type: "file_written", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/plan.md` }, - { type: "file_written", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/spec.md` }, - { type: "file_written", at: SESSION_AT, path: "src/index.ts" }, - ], - }) - ); - - await report().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(taskBacklog.asked).toStrictEqual([taskFolderPathFromIdentity(TASK)]); - }); - - it("asks the backlog about a task the journal only declared, with no file written into it", async () => { - journals.set( - "s-1", - sessionJournal("s-1", { - boundaries: [{ type: "turn_end", at: "2026-08-18T10:00:00Z" }], - taskDeclarations: [ - { type: "task_declared", at: SESSION_AT, path: `aidd_docs/tasks/${TASK}/spec.md` }, - ], - }) - ); - - await report().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(taskBacklog.asked).toStrictEqual([taskFolderPathFromIdentity(TASK)]); - }); - - it("warns nothing when every reader answered", async () => { - journals.set("s-1", sessionJournal("s-1", {})); - const read = new ReadLocalCostUseCase( - sink, - new Map([["claude", { read: async () => ({ records: [], sessionFound: true }) }]]), - journals, - NULL_PERSON_IDENTITY_READER, - new StubTelemetryEvidenceReader() - ); - - await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(logger.warnMessages).toStrictEqual([]); - }); - - it("names the tool, the session and the reader's own reason in one warning", async () => { - journals.set("s-1", sessionJournal("s-1", {})); - const read = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - throw new Error("the transcript directory is unreadable"); - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - new StubTelemetryEvidenceReader() - ); - - await report(read).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(logger.warnMessages).toStrictEqual([ - "telemetry report: claude could not be read for session s-1 - the transcript directory is unreadable", - ]); - }); -}); diff --git a/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts deleted file mode 100644 index f8fefab61..000000000 --- a/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { TelemetryOffUseCase } from "../../../../src/contexts/telemetry/application/telemetry-off-use-case.js"; -import { SESSION_TRAILER_TOKEN } from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; -import type { - CommitMessageDelegateRemoval, - VersionControl, -} from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; -import { noGit } from "../../../contexts/framework/application/helpers.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -const PROJECT_ROOT = "/repo"; -const SWITCH_PATH = join(PROJECT_ROOT, ".aidd", "config.json"); - -function buildUseCase(seed: Record = {}) { - const hasher = new DeterministicHasher(); - const fs = new InMemoryFileAdapter(seed, hasher); - const logger = new CapturingLogger(); - const evidence = new StubTelemetryEvidenceReader(); - const useCase = new TelemetryOffUseCase(fs, logger, evidence, noGit); - return { fs, logger, evidence, useCase }; -} - -describe("TelemetryOffUseCase — never on", () => { - it("succeeds and changes nothing when the project was never on", async () => { - const { fs, useCase } = buildUseCase(); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(result.switchChanged).toBe(false); - expect(fs.listAll()).toHaveLength(0); - }); - - it("prints the resolved switch path even when there is nothing to do", async () => { - const { logger, useCase } = buildUseCase(); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(logger.infoMessages).toContain(`AIDD telemetry switch -> ${SWITCH_PATH}`); - }); -}); - -describe("TelemetryOffUseCase — the switch", () => { - it("sets enabled: false, preserving the endpoint the project chose", async () => { - const seed = { - [SWITCH_PATH]: JSON.stringify({ - telemetry: { enabled: true, endpoint: "https://otel.example.com" }, - }), - }; - const { fs, useCase } = buildUseCase(seed); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.switchChanged).toBe(true); - const written = JSON.parse(fs.getFile(SWITCH_PATH) as string); - expect(written.telemetry).toEqual({ enabled: false, endpoint: "https://otel.example.com" }); - }); - - it("does not delete the switch file — deleting it would lose the endpoint", async () => { - const seed = { - [SWITCH_PATH]: JSON.stringify({ - telemetry: { enabled: true, endpoint: "https://otel.example.com" }, - }), - }; - const { fs, useCase } = buildUseCase(seed); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(fs.has(SWITCH_PATH)).toBe(true); - }); - - it("reports unchanged when the switch was already off", async () => { - const seed = { [SWITCH_PATH]: JSON.stringify({ telemetry: { enabled: false } }) }; - const { useCase } = buildUseCase(seed); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(result.switchChanged).toBe(false); - }); -}); - -describe("TelemetryOffUseCase — an endpoint configuration is untouched", () => { - it("leaves a tool's settings file exactly as `endpoint ` wrote it", async () => { - const settingsPath = join(PROJECT_ROOT, ".claude", "settings.local.json"); - const armed = JSON.stringify( - { env: { CLAUDE_CODE_ENABLE_TELEMETRY: "1", OTEL_METRICS_EXPORTER: "otlp" } }, - null, - 2 - ); - const seed = { - [SWITCH_PATH]: JSON.stringify({ telemetry: { enabled: true } }), - [settingsPath]: armed, - }; - const { fs, useCase } = buildUseCase(seed); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(fs.getFile(settingsPath)).toBe(armed); - }); -}); - -// `off` cannot clear a stale export — the writer that could is gone — so all it owes is to -// relay what it is told, by name, on `warn`; detection lives in `TelemetryEvidenceAdapter`. -describe("TelemetryOffUseCase — names a leftover export it cannot clear", () => { - it("warns with the file and the keys still set, when one is found", async () => { - const { logger, evidence, useCase } = buildUseCase(); - evidence.leftoverExport = [ - { - path: join(PROJECT_ROOT, ".claude", "settings.local.json"), - keys: ["CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_EXPORTER_OTLP_ENDPOINT"], - }, - ]; - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.warnMessages).toHaveLength(1); - expect(logger.warnMessages[0]).toContain(join(PROJECT_ROOT, ".claude", "settings.local.json")); - expect(logger.warnMessages[0]).toContain("CLAUDE_CODE_ENABLE_TELEMETRY"); - expect(logger.warnMessages[0]).toContain("OTEL_EXPORTER_OTLP_ENDPOINT"); - }); - - it("warns nothing when no leftover export is found", async () => { - const { logger, useCase } = buildUseCase(); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.warnMessages).toHaveLength(0); - }); -}); - -describe("TelemetryOffUseCase — taking back what on installed", () => { - function buildWith(result: CommitMessageDelegateRemoval, seed: Record = {}) { - const fs = new InMemoryFileAdapter(seed, new DeterministicHasher()); - const logger = new CapturingLogger(); - const asked: string[] = []; - const git: VersionControl = { - ...noGit, - removeCommitMessageDelegate: async (_root, delegateFile) => { - asked.push(delegateFile); - return result; - }, - }; - const evidence = new StubTelemetryEvidenceReader(); - return { asked, logger, useCase: new TelemetryOffUseCase(fs, logger, evidence, git) }; - } - - it("asks git to remove the delegate, whatever the switch's previous state was", async () => { - const { asked, useCase } = buildWith({ removed: true }); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(asked).toEqual(["aidd-session-trailer.sh"]); - }); - - it("says new commits carry nothing, and that the old ones keep theirs", async () => { - const { logger, useCase } = buildWith({ removed: true }); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - const said = logger.allMessages.join("\n"); - expect(said).toContain(SESSION_TRAILER_TOKEN); - expect(said).toContain("nothing here rewrites history"); - }); - - it("says nothing when there was nothing installed to take back", async () => { - const { logger, useCase } = buildWith({ removed: false }); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.allMessages.join("\n")).not.toContain(SESSION_TRAILER_TOKEN); - }); - - // A manager's own hand-added job outlives the delegate script `off` just deleted: that - // config is committed and shared, so silence leaves a live-looking line doing nothing. - it("names the manager job left behind, and says its guard now makes it a no-op", async () => { - const { logger, useCase } = buildWith({ - removed: true, - hookManager: "lefthook", - managerCallsDelegate: true, - }); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - const said = logger.allMessages.join("\n"); - expect(said).toContain("lefthook.yml"); - expect(said).toContain("still calls the delegate"); - expect(said).toMatch(/\[ -f \]|no-op|runs nothing/u); - }); - - it("says nothing about a manager job when its own config never called the delegate", async () => { - const { logger, useCase } = buildWith({ - removed: true, - hookManager: "lefthook", - managerCallsDelegate: false, - }); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.allMessages.join("\n")).not.toContain("still calls the delegate"); - }); -}); - -describe("TelemetryOffUseCase — what it says, word for word", () => { - const SWITCH_LINE = `AIDD telemetry switch -> ${SWITCH_PATH}`; - - it("says only that the switch was already off, on a project that was never on", async () => { - const { logger, useCase } = buildUseCase(); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.infoMessages).toStrictEqual([ - SWITCH_LINE, - "AIDD telemetry: already off, unchanged.", - ]); - }); - - it("says only that the switch was already off, on a switch file already off", async () => { - const seed = { [SWITCH_PATH]: JSON.stringify({ telemetry: { enabled: false } }) }; - const { logger, useCase } = buildUseCase(seed); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.infoMessages).toStrictEqual([ - SWITCH_LINE, - "AIDD telemetry: already off, unchanged.", - ]); - }); - - it("says the switch is off once it turned it off", async () => { - const seed = { [SWITCH_PATH]: JSON.stringify({ telemetry: { enabled: true } }) }; - const { logger, useCase } = buildUseCase(seed); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.infoMessages).toStrictEqual([SWITCH_LINE, "AIDD telemetry: off."]); - }); - - it("reads an unparseable switch file as off, rather than crashing on it", async () => { - const seed = { [SWITCH_PATH]: "not json" }; - const { fs, useCase } = buildUseCase(seed); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.switchChanged).toBe(false); - expect(fs.getFile(SWITCH_PATH)).toBe("not json"); - }); - - it("names the leftover export file and its keys, and what to do by hand", async () => { - const { logger, evidence, useCase } = buildUseCase(); - const settingsPath = join(PROJECT_ROOT, ".claude", "settings.local.json"); - evidence.leftoverExport = [ - { path: settingsPath, keys: ["CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_EXPORTER_OTLP_ENDPOINT"] }, - ]; - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.warnMessages).toStrictEqual([ - `${settingsPath} still sets CLAUDE_CODE_ENABLE_TELEMETRY, OTEL_EXPORTER_OTLP_ENDPOINT — ` + - "this switch cannot touch a tool's own settings file. Delete these keys from its " + - "`env` block by hand to stop that export.", - ]); - }); - - it("names the lefthook job left calling the removed delegate, word for word", async () => { - const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - const logger = new CapturingLogger(); - const git: VersionControl = { - ...noGit, - removeCommitMessageDelegate: async () => ({ - removed: true, - hookManager: "lefthook", - managerCallsDelegate: true, - }), - }; - const useCase = new TelemetryOffUseCase(fs, logger, new StubTelemetryEvidenceReader(), git); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(logger.infoMessages).toStrictEqual([ - SWITCH_LINE, - "AIDD telemetry: already off, unchanged.", - "New commits will carry no AIDD-Session-Id trailer. Commits already made " + - "keep theirs — nothing here rewrites history.", - "lefthook.yml still calls the delegate this just removed — that file is not this " + - "CLI's to edit, so the job is left in place. Its own `[ -f ]` guard now finds " + - "nothing there, so it runs nothing; delete it from " + - "lefthook.yml by hand if you want it gone too.", - ]); - }); -}); diff --git a/cli/tests/contexts/telemetry/application/telemetry-on-hook-manager.integration.test.ts b/cli/tests/contexts/telemetry/application/telemetry-on-hook-manager.integration.test.ts deleted file mode 100644 index 12eca99da..000000000 --- a/cli/tests/contexts/telemetry/application/telemetry-on-hook-manager.integration.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; -import { TelemetryOnUseCase } from "../../../../src/contexts/telemetry/application/telemetry-on-use-case.js"; -import { SESSION_TRAILER_TOKEN } from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; -import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; -import type { HookManager } from "../../../../src/contexts/telemetry/domain/telemetry-setup.js"; -import { noGit } from "../../../contexts/framework/application/helpers.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; - -const PROJECT_ROOT = "/repo"; - -/** `on` cannot promise a trailer where lefthook or husky owns `prepare-commit-msg` and - * regenerates it out from under whatever this CLI appended. */ -function managedGit(hookManager: HookManager, managerCallsDelegate: boolean): VersionControl { - return { - ...noGit, - installCommitMessageDelegate: async () => ({ - lineAdded: false, - hookManager, - managerCallsDelegate, - }), - }; -} - -function useCaseWith(git: VersionControl) { - const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - const logger = new CapturingLogger(); - return { - logger, - useCase: new TelemetryOnUseCase( - fs, - logger, - new GitignoreUseCase(fs), - git, - new InMemoryTelemetrySink() - ), - }; -} - -describe("TelemetryOnUseCase — where a manager owns prepare-commit-msg", () => { - it("prints the job to add and stops promising a trailer, for lefthook", async () => { - const { logger, useCase } = useCaseWith(managedGit("lefthook", false)); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - const said = logger.allMessages.join("\n"); - expect(said).toContain("lefthook"); - expect(said).toContain("prepare-commit-msg:"); - expect(said).toContain("lefthook.yml"); - expect(said).not.toContain("so what a session cost can be read per commit"); - }); - - it("prints the line to add and stops promising a trailer, for husky", async () => { - const { logger, useCase } = useCaseWith(managedGit("husky", false)); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - const said = logger.allMessages.join("\n"); - expect(said).toContain("husky"); - expect(said).toContain(".husky/prepare-commit-msg"); - expect(said).toContain('"$@"'); - expect(said).not.toContain("so what a session cost can be read per commit"); - }); - - it("prints nothing about the trailer once the manager's own config already calls it", async () => { - const { logger, useCase } = useCaseWith(managedGit("lefthook", true)); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - const said = logger.allMessages.join("\n"); - expect(said).not.toContain(SESSION_TRAILER_TOKEN); - expect(said).not.toContain("lefthook.yml"); - }); - - /** Under lefthook the append is wiped by the next regeneration, so the real adapter reports - * `lineAdded: true` on every `on`, never only the first. */ - it("reads hookManager over lineAdded, so a lefthook repo never gets the false promise", async () => { - const { logger, useCase } = useCaseWith(managedGit("lefthook", false)); - // Faking the adapter's own measured shape: `lineAdded` still reports `true` because the - // hook was rewritten again, but a manager owns it regardless. - const gitReportingTrueAnyway: VersionControl = { - ...noGit, - installCommitMessageDelegate: async () => ({ - lineAdded: true, - hookManager: "lefthook", - managerCallsDelegate: false, - }), - }; - const { logger: secondLogger, useCase: secondUseCase } = useCaseWith(gitReportingTrueAnyway); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - await secondUseCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - for (const said of [logger.allMessages.join("\n"), secondLogger.allMessages.join("\n")]) { - expect(said).not.toContain("so what a session cost can be read per commit"); - expect(said).toContain("lefthook.yml"); - } - }); -}); diff --git a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts deleted file mode 100644 index 4b604e524..000000000 --- a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; -import { TelemetryOnUseCase } from "../../../../src/contexts/telemetry/application/telemetry-on-use-case.js"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerDelegateScript, - sessionTrailerLefthookJob, -} from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; -import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; -import { TelemetryProjectScopeRequiresYesError } from "../../../../src/kernel/errors.js"; -import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; -import type { FileWriter } from "../../../../src/kernel/ports/file-writer.js"; -import { noGit } from "../../../contexts/framework/application/helpers.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; - -/** Fails a write to one path alone — a disk-full or permission failure on a single file — - * so a write failing afterwards must not leave the switch written anyway. */ -class ThrowingWriteAdapter implements FileReader, FileWriter { - constructor( - private readonly inner: InMemoryFileAdapter, - private readonly failingPath: string - ) {} - - readFile(path: string): Promise { - return this.inner.readFile(path); - } - listDirectory(path: string): Promise { - return this.inner.listDirectory(path); - } - fileExists(path: string): Promise { - return this.inner.fileExists(path); - } - readFileHash(path: string): ReturnType { - return this.inner.readFileHash(path); - } - listFilesRecursive(dirPath: string): Promise { - return this.inner.listFilesRecursive(dirPath); - } - isExecutable(path: string): Promise { - return this.inner.isExecutable(path); - } - realpath(path: string): Promise { - return this.inner.realpath(path); - } - async writeFile(path: string, content: string): Promise { - if (path === this.failingPath) throw new Error(`disk full writing ${path}`); - await this.inner.writeFile(path, content); - } - deleteFile(path: string): Promise { - return this.inner.deleteFile(path); - } - createDirectory(path: string): Promise { - return this.inner.createDirectory(path); - } - deleteEmptyDirectories(path: string): Promise { - return this.inner.deleteEmptyDirectories(path); - } - deleteDirectory(path: string): Promise { - return this.inner.deleteDirectory(path); - } - chmodExecutable(path: string): Promise { - return this.inner.chmodExecutable(path); - } -} - -const PROJECT_ROOT = "/repo"; -const SWITCH_PATH = join(PROJECT_ROOT, ".aidd", "config.json"); -const LOCAL_SETTINGS_PATH = join(PROJECT_ROOT, ".claude", "settings.local.json"); - -function buildUseCase(seed: Record = {}) { - const hasher = new DeterministicHasher(); - const fs = new InMemoryFileAdapter(seed, hasher); - const logger = new CapturingLogger(); - const sink = new InMemoryTelemetrySink(); - const useCase = new TelemetryOnUseCase(fs, logger, new GitignoreUseCase(fs), noGit, sink); - return { fs, logger, useCase, sink }; -} - -describe("TelemetryOnUseCase — the switch alone", () => { - it("refuses when the records directory cannot be written, rather than losing them later", async () => { - // `appendRecord` creates the directory itself, so without this the first failure comes - // at the first record — to whoever runs `read`, not to whoever turned it on. - const { fs, useCase, sink } = buildUseCase(); - sink.unwritable = true; - - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true })).rejects.toThrow( - "not writable" - ); - expect(fs.has(SWITCH_PATH)).toBe(false); - }); - - it("a gitignore write that fails leaves the switch unwritten, never enabled: true over a half-finished setup", async () => { - const inner = new InMemoryFileAdapter({}, new DeterministicHasher()); - // Matches GitignoreUseCase's own literal "/" join, never `join()`, which would key this - // adapter's failing path differently from the one the use case actually writes. - const fs = new ThrowingWriteAdapter(inner, `${PROJECT_ROOT}/.gitignore`); - const logger = new CapturingLogger(); - const useCase = new TelemetryOnUseCase( - fs, - logger, - new GitignoreUseCase(fs), - noGit, - new InMemoryTelemetrySink() - ); - - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true })).rejects.toThrow( - "disk full" - ); - expect(inner.has(SWITCH_PATH)).toBe(false); - }); - - it("succeeds with no endpoint anywhere, and writes no tool's settings file", async () => { - const { fs, useCase } = buildUseCase(); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(result.switchChanged).toBe(true); - const written = JSON.parse(fs.getFile(SWITCH_PATH) ?? "null"); - expect(written.telemetry).toEqual({ enabled: true }); - expect(fs.has(LOCAL_SETTINGS_PATH)).toBe(false); - }); - - it("prints the resolved switch path before writing anything", async () => { - const { logger, useCase } = buildUseCase(); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - expect(logger.infoMessages[0]).toBe(`AIDD telemetry switch -> ${SWITCH_PATH}`); - }); - - it("preserves an endpoint already recorded in the switch file — `on` has no opinion on it", async () => { - const seed = { - [SWITCH_PATH]: JSON.stringify({ - telemetry: { enabled: false, endpoint: "https://otel.example.com" }, - }), - }; - const { fs, useCase } = buildUseCase(seed); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - const written = JSON.parse(fs.getFile(SWITCH_PATH) as string); - expect(written.telemetry).toEqual({ enabled: true, endpoint: "https://otel.example.com" }); - }); - - it("enabling twice reports the switch unchanged the second time", async () => { - const { useCase } = buildUseCase(); - const first = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - const second = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - expect(first.switchChanged).toBe(true); - expect(second.switchChanged).toBe(false); - }); -}); - -describe("TelemetryOnUseCase — the same consent `endpoint --scope project` already demands", () => { - it("without --yes, refuses and writes nothing, naming the consequence", async () => { - const { fs, useCase } = buildUseCase(); - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( - TelemetryProjectScopeRequiresYesError - ); - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( - /everyone who clones/ - ); - expect(fs.has(SWITCH_PATH)).toBe(false); - }); - - it("fires even when the switch is already on — the same unconditional guard `endpoint` uses", async () => { - const { fs, useCase } = buildUseCase(); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - expect(fs.has(SWITCH_PATH)).toBe(true); - - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( - TelemetryProjectScopeRequiresYesError - ); - }); - - it("with --yes, writes the switch", async () => { - const { fs, useCase } = buildUseCase(); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - expect(result.switchChanged).toBe(true); - expect(fs.has(SWITCH_PATH)).toBe(true); - }); -}); - -describe("TelemetryOnUseCase — making commits joinable to the session that made them", () => { - /** Holds the decision — install, then say so — apart from the mechanics of writing a hook, - * which the adapter's own integration suite proves against real repositories. */ - function recordingGit(lineAdded: boolean) { - const calls: { delegateFile: string; script: string }[] = []; - const git: VersionControl = { - ...noGit, - installCommitMessageDelegate: async (_root, delegateFile, script) => { - calls.push({ delegateFile, script }); - return { lineAdded }; - }, - }; - return { calls, git }; - } - - function useCaseWith(git: VersionControl) { - const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - const logger = new CapturingLogger(); - return { - fs, - logger, - useCase: new TelemetryOnUseCase( - fs, - logger, - new GitignoreUseCase(fs), - git, - new InMemoryTelemetrySink() - ), - }; - } - - it("installs the delegate the domain declares, never a script written out a second time", async () => { - const { calls, git } = recordingGit(true); - const { useCase } = useCaseWith(git); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(calls).toHaveLength(1); - expect(calls[0]?.delegateFile).toBe(SESSION_TRAILER_DELEGATE_FILE); - expect(calls[0]?.script).toBe(sessionTrailerDelegateScript()); - }); - - it("says what it will write into commit messages, and how to undo it", async () => { - const { git } = recordingGit(true); - const { logger, useCase } = useCaseWith(git); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - const said = logger.allMessages.join("\n"); - expect(said).toContain(SESSION_TRAILER_TOKEN); - expect(said).toContain("aidd telemetry off"); - }); - - it("says nothing when it was already installed - a no-op is not news", async () => { - const { git } = recordingGit(false); - const { logger, useCase } = useCaseWith(git); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(logger.allMessages.join("\n")).not.toContain(SESSION_TRAILER_TOKEN); - }); - - it("installs on every successful on, so a project turned on before this is caught up", async () => { - const { calls, git } = recordingGit(false); - const { useCase } = useCaseWith(git); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(calls).toHaveLength(2); - }); -}); - -describe("TelemetryOnUseCase — what it says, word for word", () => { - const SWITCH_LINE = `AIDD telemetry switch -> ${SWITCH_PATH}`; - const IGNORED_LINE = - "Added aidd_docs/runs/ to .gitignore — the journal names no person, only the " + - "repository, the task folders written into, the skills run, and their timings. " + - "Delete that line to commit it instead."; - - it("names the command in the refusal it throws without --yes", async () => { - const { useCase } = buildUseCase(); - - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( - `aidd telemetry on writes the git-tracked ${SWITCH_PATH}, turning telemetry on for ` + - "everyone who clones. Pass --yes to confirm." - ); - }); - - it("says the journal was git-ignored, then that the switch is on, and nothing else", async () => { - const { logger, useCase } = buildUseCase(); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(logger.infoMessages).toStrictEqual([SWITCH_LINE, IGNORED_LINE, "AIDD telemetry: on."]); - expect(logger.warnMessages).toStrictEqual([]); - }); - - it("says only that the switch was already on, the second time", async () => { - const { logger, useCase } = buildUseCase(); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - logger.reset(); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(logger.infoMessages).toStrictEqual([ - SWITCH_LINE, - "AIDD telemetry: already on, unchanged.", - ]); - }); - - it("warns which journal files git already tracks, and touches none of them", async () => { - const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - const logger = new CapturingLogger(); - const git: VersionControl = { - ...noGit, - listTrackedFiles: async () => ["aidd_docs/runs/a.jsonl", "aidd_docs/runs/b.jsonl"], - }; - const useCase = new TelemetryOnUseCase( - fs, - logger, - new GitignoreUseCase(fs), - git, - new InMemoryTelemetrySink() - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(logger.warnMessages).toStrictEqual([ - "Already tracked by git — the repository, the task folders written into, the skills " + - "run, and their timings:\n aidd_docs/runs/a.jsonl\n aidd_docs/runs/b.jsonl\n" + - "Nothing removed or rewritten — your call.", - ]); - }); - - it("says what the trailer line it installed does, and the command undoing it", async () => { - const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - const logger = new CapturingLogger(); - const git: VersionControl = { - ...noGit, - installCommitMessageDelegate: async () => ({ lineAdded: true }), - }; - const useCase = new TelemetryOnUseCase( - fs, - logger, - new GitignoreUseCase(fs), - git, - new InMemoryTelemetrySink() - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(logger.infoMessages).toStrictEqual([ - SWITCH_LINE, - IGNORED_LINE, - "Commits made by an AI session will carry an AIDD-Session-Id trailer, so what " + - "a session cost can be read per commit. A commit no session made carries nothing. " + - "`aidd telemetry off` removes it.", - "AIDD telemetry: on.", - ]); - }); - - it("prints the job to add by hand when lefthook owns the hook and does not call the delegate yet", async () => { - const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - const logger = new CapturingLogger(); - const git: VersionControl = { - ...noGit, - installCommitMessageDelegate: async () => ({ - lineAdded: false, - hookManager: "lefthook", - managerCallsDelegate: false, - }), - }; - const useCase = new TelemetryOnUseCase( - fs, - logger, - new GitignoreUseCase(fs), - git, - new InMemoryTelemetrySink() - ); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(logger.infoMessages).toStrictEqual([ - SWITCH_LINE, - IGNORED_LINE, - "lefthook owns prepare-commit-msg here, so nothing was appended to it. Commits will " + - "not carry an AIDD-Session-Id trailer until you " + - "add this command under `prepare-commit-msg:` in lefthook.yml:\n\n" + - `${sessionTrailerLefthookJob(SESSION_TRAILER_DELEGATE_FILE)}\n`, - "AIDD telemetry: on.", - ]); - }); -}); diff --git a/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts b/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts deleted file mode 100644 index 81c5a609b..000000000 --- a/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -// Side-effect imports: the use-case resolves each tool's declaration from the registry, -// so every AI tool must be registered for these tests to see Claude Code's and Codex's. -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; -import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; -import type { SessionCostReader } from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { AI_TOOL_IDS } from "../../../../src/kernel/tool.js"; -import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; -import { NULL_RUN_JOURNAL_READER } from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -const TRANSCRIPT_SESSION_ID = "22222222-2222-4222-8222-222222222222"; -const PROJECT_ROOT = "/repo"; - -function loadCapturedTranscript(): string { - const url = new URL( - `../../../fixtures/local-cost/.claude/projects/fake-project/${TRANSCRIPT_SESSION_ID}.jsonl`, - import.meta.url - ); - return readFileSync(fileURLToPath(url), "utf8"); -} - -function readSourceFile(relativePathFromSrc: string): string { - const url = new URL(`../../../../src/${relativePathFromSrc}`, import.meta.url); - return readFileSync(fileURLToPath(url), "utf8"); -} - -/** Only the file-walking adapter is stubbed: the captured transcript is parsed by the real - * mapper, so this stays a unit test and still proves the use case's own stamping. */ -async function readCapturedTranscript(): Promise<{ - readonly sink: InMemoryTelemetrySink; - readonly records: readonly TelemetrySinkRecord[]; -}> { - const candidates = mapClaudeCodeTranscriptToSinkRecords(loadCapturedTranscript()); - const stubReader: SessionCostReader = { - read: async () => ({ records: candidates, sessionFound: true }), - }; - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - new StubTelemetryEvidenceReader() - ); - await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: TRANSCRIPT_SESSION_ID, - }); - return { sink, records: [...sink.files.values()].flat() }; -} - -describe("every stored record names its tool", () => { - it("names a tool on every record produced from a captured transcript", async () => { - const { records } = await readCapturedTranscript(); - expect(records.length).toBeGreaterThan(0); - expect(records.every((record) => record.tool !== undefined)).toBe(true); - }); - - it("names only a declared tool identifier, never a free string", async () => { - const { records } = await readCapturedTranscript(); - for (const record of records) { - expect(AI_TOOL_IDS).toContain(record.tool); - } - }); - - it("names the tool consistently, and the vendor field it read the identity from", async () => { - const { records } = await readCapturedTranscript(); - expect(records.every((record) => record.tool === "claude")).toBe(true); - expect(records[0]?.vendor_field).toBe("sessionId"); - }); - - // Derived from AI_TOOL_IDS, never hand-listed: a hardcoded name would defeat the criterion - // it proves, that adding a tool is a declaration the use case is never told about by name. - it("contains no tool name, by string literal, in the local-read use-case", () => { - const source = readSourceFile("contexts/telemetry/application/read-local-cost-use-case.ts"); - for (const toolId of AI_TOOL_IDS) { - expect(source).not.toContain(`"${toolId}"`); - expect(source).not.toContain(`'${toolId}'`); - } - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/cost-report-backlog.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-backlog.unit.test.ts deleted file mode 100644 index 8c0de3403..000000000 --- a/cli/tests/contexts/telemetry/domain/cost-report-backlog.unit.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildCostReport, - type CostReportInput, - type CostReportSessionJournal, - type CostTotals, - toMicroUsd, -} from "../../../../src/contexts/telemetry/domain/cost-report.js"; -import type { TaskBacklogDeclaration } from "../../../../src/contexts/telemetry/domain/task-backlog-link.js"; -import type { TaskIdentity } from "../../../../src/contexts/telemetry/domain/task-identity.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-multi", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function request(overrides: Partial = {}): TelemetrySinkRecord { - return { ...BASE, ...overrides }; -} - -const NO_CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -function report(overrides: Partial = {}) { - return buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }); -} - -function sumOf(rows: readonly { readonly totals: CostTotals }[]): CostTotals { - return rows.reduce( - (accumulator, row) => ({ - requests: accumulator.requests + row.totals.requests, - costMicroUsd: (accumulator.costMicroUsd ?? 0) + (row.totals.costMicroUsd ?? 0), - }), - { requests: 0, costMicroUsd: 0 } - ); -} - -// Four tasks declared in sequence, two naming the same backlog item (the merge this axis -// exists for), one naming none, one unreadable; a fifth record precedes them all. -const ITEM_TASK_A = "2026_08/item-task-a"; -const ITEM_TASK_B = "2026_08/item-task-b"; -const NONE_TASK = "2026_08/none-task"; -const UNREADABLE_TASK = "2026_08/unreadable-task"; -const BACKLOG_ITEM = "acme/repo#42"; - -const JOURNALS: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-multi", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [ - { - path: `aidd_docs/tasks/${ITEM_TASK_A}/spec.md`, - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - }, - { - path: `aidd_docs/tasks/${ITEM_TASK_B}/spec.md`, - startMs: Date.parse("2026-08-17T11:00:00Z"), - endMs: Date.parse("2026-08-17T12:00:00Z"), - }, - { - path: `aidd_docs/tasks/${NONE_TASK}/spec.md`, - startMs: Date.parse("2026-08-17T12:00:00Z"), - endMs: Date.parse("2026-08-17T13:00:00Z"), - }, - { - path: `aidd_docs/tasks/${UNREADABLE_TASK}/spec.md`, - startMs: Date.parse("2026-08-17T13:00:00Z"), - endMs: Date.parse("2026-08-17T14:00:00Z"), - }, - ], - flowIntervals: [], - }, -]; - -function linkTo(backlog: string): TaskBacklogDeclaration { - return { - kind: "declared", - link: { backlog, writtenAt: "2026-08-17T09:00:00Z", writtenBy: "aidd-pm:04-spec" }, - }; -} - -const DECLARATIONS: ReadonlyMap = new Map([ - [ITEM_TASK_A, linkTo(BACKLOG_ITEM)], - [ITEM_TASK_B, linkTo(BACKLOG_ITEM)], - [NONE_TASK, { kind: "none" }], - [UNREADABLE_TASK, { kind: "unreadable" }], -]); - -const RECORDS: readonly TelemetrySinkRecord[] = [ - // Before any declaration - the axis' own reason row, unchanged from byTasks. - request({ turn_id: "before", cost_usd: 1, event_timestamp: "2026-08-17T09:30:00Z" }), - request({ turn_id: "item-a", cost_usd: 10, event_timestamp: "2026-08-17T10:30:00Z" }), - request({ turn_id: "item-b", cost_usd: 5, event_timestamp: "2026-08-17T11:30:00Z" }), - request({ turn_id: "none", cost_usd: 3, event_timestamp: "2026-08-17T12:30:00Z" }), - request({ turn_id: "unreadable", cost_usd: 2, event_timestamp: "2026-08-17T13:30:00Z" }), -]; - -describe("buildCostReport — by_backlog regroups tasks by what their folder declares", () => { - it("merges two tasks declaring the same item into one row", () => { - const built = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: DECLARATIONS, - }); - - const named = built.byBacklog.filter((row) => row.backlog !== undefined); - expect(named).toHaveLength(1); - expect(named[0]?.backlog).toBe(BACKLOG_ITEM); - expect(named[0]?.totals.requests).toBe(2); - expect(named[0]?.totals.costMicroUsd).toBe(toMicroUsd(15)); - }); - - it("gives a task declaring none its own row, distinct from a record in no task at all", () => { - const built = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: DECLARATIONS, - }); - - const none = built.byBacklog.find((row) => row.declaration === "none"); - expect(none).toBeDefined(); - expect(none?.totals.requests).toBe(1); - expect(none?.totals.costMicroUsd).toBe(toMicroUsd(3)); - expect(none?.reason).toBeUndefined(); - }); - - it("gives a damaged declaration its own row, costing only its own resolution", () => { - const built = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: DECLARATIONS, - }); - - const unreadable = built.byBacklog.find((row) => row.declaration === "unreadable"); - expect(unreadable).toBeDefined(); - expect(unreadable?.totals.requests).toBe(1); - expect(unreadable?.totals.costMicroUsd).toBe(toMicroUsd(2)); - }); - - it("carries a record in no task at all through as its own reason row, unchanged", () => { - const built = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: DECLARATIONS, - }); - - const reasonRow = built.byBacklog.find((row) => row.reason !== undefined); - expect(reasonRow?.reason).toBe("precedes-declaration"); - expect(reasonRow?.totals.requests).toBe(1); - expect(reasonRow?.totals.costMicroUsd).toBe(toMicroUsd(1)); - }); - - it("reconciles to the same total as the task, step, model, person and project axes", () => { - const built = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: DECLARATIONS, - }); - const expected = { requests: built.totals.requests, costMicroUsd: built.totals.costMicroUsd }; - - expect(sumOf(built.byBacklog)).toEqual(expected); - expect(sumOf(built.byTasks)).toEqual(expected); - expect(sumOf(built.bySteps)).toEqual(expected); - expect(sumOf(built.byModels)).toEqual(expected); - expect(sumOf(built.byProjects)).toEqual(expected); - expect(sumOf(built.byPeople)).toEqual(expected); - }); - - it("orders named items largest first, then none, then unreadable, then every reason", () => { - const built = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: DECLARATIONS, - }); - - expect(built.byBacklog.map((row) => row.backlog ?? row.declaration ?? row.reason)).toEqual([ - BACKLOG_ITEM, - "none", - "unreadable", - "precedes-declaration", - ]); - }); - - it("a task with no entry in the resolved declarations still counts, defaulting to none rather than dropping the record", () => { - // A real report always resolves every task identity its journals name, so this pins - // the defensive default `taskRowOf` falls back to were that ever not true. - const built = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: new Map(), - }); - - expect(sumOf(built.byBacklog).requests).toBe(RECORDS.length); - const none = built.byBacklog.find((row) => row.declaration === "none"); - expect(none?.totals.requests).toBe(4); // every declared-task record, none resolved - }); - - it("mutation proof: a task declaring no item is never silently merged into one that declared", () => { - // The none row and the named row must stay disjoint unless a declaration is actually - // changed: making "none" declare the merge test's item would move its record over. - const built = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: DECLARATIONS, - }); - const named = built.byBacklog.find((row) => row.backlog === BACKLOG_ITEM); - const none = built.byBacklog.find((row) => row.declaration === "none"); - expect(named?.totals.requests).toBe(2); - expect(none?.totals.requests).toBe(1); - - const mutatedDeclarations = new Map(DECLARATIONS); - mutatedDeclarations.set(NONE_TASK, linkTo(BACKLOG_ITEM)); - const mutated = report({ - records: RECORDS, - journals: JOURNALS, - taskBacklogDeclarations: mutatedDeclarations, - }); - const mutatedNamed = mutated.byBacklog.find((row) => row.backlog === BACKLOG_ITEM); - expect(mutatedNamed?.totals.requests).toBe(3); - expect(mutated.byBacklog.find((row) => row.declaration === "none")).toBeUndefined(); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/cost-report-contract.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-contract.unit.test.ts deleted file mode 100644 index eede37264..000000000 --- a/cli/tests/contexts/telemetry/domain/cost-report-contract.unit.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { describe, expect, it } from "vitest"; -import { COST_REPORT_ENVELOPE_VERSION } from "../../../../src/contexts/telemetry/domain/cost-report-envelope.js"; -import { STEP_ATTRIBUTION_SOURCES } from "../../../../src/contexts/telemetry/domain/step-attribution.js"; -import { TASK_UNATTRIBUTED_REASONS } from "../../../../src/contexts/telemetry/domain/task-attribution.js"; -import { ARTEFACT_AXES } from "../../../../src/presentation/display/cost-report-artefact.js"; -import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; - -// Never a hand-maintained list on either side: only the code's own exported values and the -// document's own prose, both read fresh off disk. - -const CONTRACT_DOC_URL = pathToFileURL( - join(REPOSITORY_ROOT, "aidd_docs", "product", "cost-report-contract.md") -); - -function contractText(): string { - return readFileSync(fileURLToPath(CONTRACT_DOC_URL), "utf8"); -} - -describe("the cost report contract document", () => { - it("names every reason a row with no task can carry", () => { - const document = contractText(); - - // Required as a table cell, not merely somewhere in the prose: a reason named only in a - // version note is still missing from the table a reader parses against. - const undocumented = TASK_UNATTRIBUTED_REASONS.filter( - (reason) => !document.includes(`| \`"${reason}"\` |`) - ); - - expect(undocumented).toEqual([]); - }); - - // A journal file really can be read and still yield no session - `report-cost-use-case.ts` - // drops one whose `session_start` header is torn - so the word this hinges on is "usable". - it("never claims the unattributed reason means no journal existed", () => { - const document = contractText(); - - expect(document).not.toMatch(/no run journal was read for this record's session at all/i); - expect(document).toContain("no usable run journal"); - }); - - it("states the envelope version the code actually emits", () => { - const stated = /Every object carries `cost_report_version`, currently `(\d+)`/.exec( - contractText() - ); - - expect(stated?.[1]).toBe(String(COST_REPORT_ENVELOPE_VERSION)); - }); - - // A reader parses against the worked example, so pinning only the prose sentence above it - // guards the half nobody copies. - it("shows that same version in its own worked example", () => { - const shown = /"cost_report_version": (\d+)/.exec(contractText()); - - expect(shown?.[1]).toBe(String(COST_REPORT_ENVELOPE_VERSION)); - }); - - // The same drift the task-reason table already guards against: a source can join the - // code's own fixed order and never join the table a reader parses `attribution` against. - it("names every source a step's attribution can carry", () => { - const document = contractText(); - - const undocumented = STEP_ATTRIBUTION_SOURCES.filter( - (source) => !document.includes(`| \`${source}\` |`) - ); - - expect(undocumented).toEqual([]); - }); - - // The quoted example in the `--axis` usage error is prose, not code - it drifts the same - // way the worked example above does, silently, the moment a new axis is added. - it("quotes the exact axis list --axis's own usage error prints", () => { - const document = contractText(); - - expect(document).toContain(ARTEFACT_AXES.join(", ")); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts deleted file mode 100644 index 10031afc9..000000000 --- a/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts +++ /dev/null @@ -1,598 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { - buildCostReport, - type CostReport, - type CostReportInput, - type CostReportToolDeclaration, -} from "../../../../src/contexts/telemetry/domain/cost-report.js"; -import { - COST_REPORT_ENVELOPE_VERSION, - toCostReportEnvelope, -} from "../../../../src/contexts/telemetry/domain/cost-report-envelope.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { printCostReport } from "../../../../src/presentation/display/cost-report-display.js"; -import { CapturingOutput } from "../../../helpers/ports/capturing-output.js"; - -const DECLARED: readonly CostReportToolDeclaration[] = [ - { - tool: "claude", - coverage: "covered", - capability: { - localRead: { tokenCounters: true, amount: false, toolStatedStep: true, agentName: true }, - export: { tokenCounters: true, amount: true, toolStatedStep: false, agentName: false }, - journalAttributable: true, - taskAttributable: true, - }, - }, - { - tool: "cursor", - coverage: "not-covered", - reason: "It writes no token count in any file it produces.", - capability: { - localRead: null, - export: null, - journalAttributable: true, - taskAttributable: false, - }, - }, - { - tool: "copilot", - coverage: "covered", - reason: - "Its own file names outputTokens per turn, but session.shutdown carries all four " + - "counters for the whole session — a session total, never a sum of requests.", - capability: { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - export: { tokenCounters: false, amount: false, toolStatedStep: false, agentName: false }, - journalAttributable: true, - taskAttributable: false, - }, - }, -]; - -function record(overrides: Partial = {}): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - ...overrides, - }; -} - -function envelopeOf(overrides: Partial = {}) { - return toCostReportEnvelope( - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: DECLARED, - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }) - ); -} - -describe("toCostReportEnvelope", () => { - it("carries a version a consumer can refuse", () => { - expect(envelopeOf().cost_report_version).toBe(COST_REPORT_ENVELOPE_VERSION); - }); - - it("carries the period absolutely, as it resolved", () => { - expect(envelopeOf().period).toEqual({ from_day: "2026-08-17", to_day: "2026-08-21" }); - }); - - // --json had no way to say measurement was off at all, breaking the constraint that a - // report states whether measurement is on. - it("carries measurement_enabled, the one field the terminal rendering could see and this could not", () => { - expect(envelopeOf({ measurementEnabled: true }).measurement_enabled).toBe(true); - expect(envelopeOf({ measurementEnabled: false }).measurement_enabled).toBe(false); - }); - - it("carries no filters object at all for an unfiltered period", () => { - expect(envelopeOf().filters).toBeUndefined(); - }); - - it("carries only the generic filters given, snake_case field names untouched", () => { - const envelope = envelopeOf({ - records: [record({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets" })], - filters: { project: "acme/widgets" }, - }); - - expect(envelope.filters).toEqual({ project: "acme/widgets" }); - }); - - it("names the filter that emptied a selection, distinguishing known from never seen", () => { - const envelope = envelopeOf({ - records: [record({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets" })], - knownValues: { projects: new Set(["acme/widgets"]), steps: new Set(), models: new Set() }, - filters: { project: "never-worked-here" }, - }); - - expect(envelope.empty_selection).toEqual({ - filter: "project", - value: "never-worked-here", - known: false, - }); - }); - - it("keeps an absent counter absent, never turning it into a zero", () => { - const envelope = envelopeOf({ records: [record({ input_tokens: 0 })] }); - - expect(envelope.totals.input_tokens).toBe(0); - expect(envelope.totals).not.toHaveProperty("output_tokens"); - expect(envelope.totals).not.toHaveProperty("cost_micro_usd"); - }); - - it("carries money as whole micro-dollars, so summing reports stays exact", () => { - expect(envelopeOf({ records: [record({ cost_usd: 4.2 })] }).totals.cost_micro_usd).toBe( - 4200000 - ); - }); - - it("says what each tool can supply on each route, from its declaration", () => { - const byTool = Object.fromEntries( - envelopeOf().by_tool.map((row) => [row.tool, row.capability]) - ); - - expect(byTool.claude).toEqual({ - local_read: { - token_counters: true, - amount: false, - tool_stated_step: true, - agent_name: true, - }, - export: { - token_counters: true, - amount: true, - tool_stated_step: false, - agent_name: false, - }, - journal_attributable: true, - task_attributable: true, - }); - // Null is not "supplies nothing": this tool declares no such route at all. - expect(byTool.cursor).toEqual({ - local_read: null, - export: null, - journal_attributable: true, - task_attributable: false, - }); - }); - - it("carries session_totals snake_case, beside the ordinary totals, only where measured (#697)", () => { - const withCopilot = envelopeOf({ - records: [ - record({ - tool: "copilot", - kind: "session", - provenance: "local-read", - input_tokens: 10, - output_tokens: 42, - cache_read_tokens: 0, - cache_creation_tokens: 21070, - }), - ], - }); - const copilot = withCopilot.by_tool.find((row) => row.tool === "copilot"); - const claude = withCopilot.by_tool.find((row) => row.tool === "claude"); - - expect(copilot?.session_totals).toEqual({ - requests: 0, - input_tokens: 10, - output_tokens: 42, - cache_read_tokens: 0, - cache_creation_tokens: 21070, - }); - expect(copilot?.totals).toEqual({ requests: 0 }); - expect(claude).not.toHaveProperty("session_totals"); - }); - - it("carries why an uncovered tool cannot be read", () => { - const cursor = envelopeOf().by_tool.find((row) => row.tool === "cursor"); - - expect(cursor?.coverage).toBe("not-covered"); - expect(cursor?.reason).toBe("It writes no token count in any file it produces."); - }); - - it("carries all four attribution strengths, strongest first, zeros included", () => { - expect(envelopeOf().attribution.map((row) => row.attribution)).toEqual([ - "tool-stated", - "prompt-matched", - "journal-interval", - "unattributed", - ]); - }); - - it("gives a record with no project its own row, project absent rather than a placeholder", () => { - const envelope = envelopeOf({ - records: [ - record({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets" }), - record({ turn_id: "b", cost_usd: 1 }), - ], - }); - - expect(envelope.by_project.find((row) => row.project === "acme/widgets")).toBeDefined(); - const unknown = envelope.by_project.find((row) => !("project" in row)); - expect(unknown?.totals.requests).toBe(1); - }); - - // snake_case on the wire like every other row, and `started_at` beside the id because an - // opaque prompt id alone is not something a person can look up. - it("carries one row per prompt, dated, and one undated row for records that named none", () => { - const envelope = envelopeOf({ - records: [ - record({ turn_id: "a", prompt_id: "p-1", event_timestamp: "2026-08-18T09:00:00.000Z" }), - record({ turn_id: "b", prompt_id: "p-1", event_timestamp: "2026-08-18T09:05:00.000Z" }), - record({ turn_id: "c", event_timestamp: "2026-08-18T10:00:00.000Z" }), - ], - }); - - expect( - envelope.by_prompt.map((row) => [row.prompt, row.started_at, row.totals.requests]) - ).toEqual([ - ["p-1", "2026-08-18T09:00:00Z", 2], - [undefined, undefined, 1], - ]); - }); - - it("carries every day the period spans, a gap included, never sorted by size", () => { - const envelope = envelopeOf({ - records: [ - record({ turn_id: "a", cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" }), - record({ turn_id: "b", cost_usd: 5, event_timestamp: "2026-08-21T10:00:00Z" }), - ], - }); - - expect(envelope.by_day.map((row) => row.day)).toEqual([ - "2026-08-17", - "2026-08-18", - "2026-08-19", - "2026-08-20", - "2026-08-21", - ]); - expect(envelope.by_day.find((row) => row.day === "2026-08-18")?.totals).toEqual({ - requests: 0, - }); - }); - - it("carries what the read could not place and could not parse", () => { - expect(envelopeOf({ undatedRecords: 3, unreadableLines: 2 }).read).toEqual({ - undated_records: 3, - unreadable_lines: 2, - }); - }); - - it("serializes an empty period to a valid object rather than to nothing", () => { - const envelope = envelopeOf(); - - expect(envelope.sessions).toBe(0); - expect(envelope.totals.requests).toBe(0); - expect(envelope.by_step).toEqual([]); - expect(JSON.parse(JSON.stringify(envelope))).toEqual(envelope); - }); - - it("reads no clock and no filesystem", () => { - const source = readFileSync( - fileURLToPath( - new URL( - "../../../../src/contexts/telemetry/domain/cost-report-envelope.ts", - import.meta.url - ) - ), - "utf8" - ); - - expect(source).not.toContain("node:fs"); - expect(source).not.toContain("Date"); - }); - - // A bump is one edit; the contract naming the new version is a second one nothing forces. - it("is the version the product contract names as current", () => { - const contract = readFileSync( - join(REPOSITORY_ROOT, "aidd_docs", "product", "cost-report-contract.md"), - "utf8" - ); - - expect(contract).toContain(`currently \`${COST_REPORT_ENVELOPE_VERSION}\``); - }); -}); - -const CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -const BARE_REPORT: CostReport = { - fromDay: "2026-08-17", - toDay: "2026-08-17", - sessions: 1, - totals: { requests: 1 }, - bySteps: [{ attribution: "unattributed", totals: { requests: 1 } }], - byModels: [{ totals: { requests: 1 } }], - byAgents: [{ attribution: "not-stated", totals: { requests: 1 } }], - byPrompts: [{ totals: { requests: 1 } }], - byTools: [ - { tool: "codex", coverage: "covered", capability: CAPABILITY, totals: { requests: 1 } }, - ], - byProjects: [{ totals: { requests: 1 } }], - byTasks: [{ totals: { requests: 1 } }], - byBacklog: [{ totals: { requests: 1 } }], - byFlows: [{ attribution: "unattributed", totals: { requests: 1 } }], - byDays: [{ day: "2026-08-17", totals: { requests: 1 } }], - byPeople: [{ resolution: "none", identities: [], totals: { requests: 1 } }], - attributionMix: [{ attribution: "unattributed", totals: { requests: 1 } }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, -}; - -const FULL_TOTALS = { - requests: 2, - costMicroUsd: 1500000, - inputTokens: 10, - outputTokens: 20, - cacheReadTokens: 30, - cacheCreationTokens: 40, -} as const; - -const FULL_TOTALS_RENDERED = { - requests: 2, - cost_micro_usd: 1500000, - input_tokens: 10, - output_tokens: 20, - cache_read_tokens: 30, - cache_creation_tokens: 40, -} as const; - -const FULL_REPORT: CostReport = { - fromDay: "2026-08-17", - toDay: "2026-08-18", - task: "2026_08/widgets", - filters: { project: "acme/widgets", model: "opus" }, - emptySelection: { filter: "model", value: "opus", known: true, combination: true }, - sessions: 2, - totals: FULL_TOTALS, - activeTimeSeconds: 754, - bySteps: [{ step: "implement", attribution: "tool-stated", totals: FULL_TOTALS }], - byModels: [{ model: "opus", totals: FULL_TOTALS }], - byAgents: [{ agent: "Explore", attribution: "tool-stated", totals: FULL_TOTALS }], - byPrompts: [{ prompt: "p-1", startedAt: "2026-08-17T09:00:00Z", totals: FULL_TOTALS }], - byTools: [ - { - tool: "copilot", - coverage: "not-covered", - reason: "A session total only.", - capability: CAPABILITY, - totals: FULL_TOTALS, - sessionTotals: { requests: 0, outputTokens: 5 }, - }, - ], - byProjects: [{ project: "acme/widgets", totals: FULL_TOTALS }], - byTasks: [ - { task: "2026_08/widgets", attribution: "declared", totals: FULL_TOTALS }, - { reason: "no-declaration", totals: FULL_TOTALS }, - ], - byBacklog: [ - { backlog: "STORY-7", totals: FULL_TOTALS }, - { declaration: "unreadable", totals: FULL_TOTALS }, - { reason: "no-journal", totals: FULL_TOTALS }, - ], - byFlows: [ - { - flow: "aidd-orchestrator:01-sdlc", - attribution: "journal-interval", - startedAt: "2026-08-17T08:00:00Z", - totals: FULL_TOTALS, - }, - ], - byDays: [{ day: "2026-08-17", totals: FULL_TOTALS }], - byPeople: [ - { - resolution: "mapped", - person: "ada", - displayName: "Ada L.", - identities: ["ada@example.test"], - totals: FULL_TOTALS, - }, - ], - attributionMix: [{ attribution: "tool-stated", totals: FULL_TOTALS }], - taskAttributionMix: [{ attribution: "declared", totals: FULL_TOTALS }], - undatedRecords: 3, - unreadableLines: 4, - identityUnusableCause: "unreadable", - measurementEnabled: false, -}; - -describe("toCostReportEnvelope renders a report value field for field", () => { - it("leaves every optional field out entirely, never present as undefined, when the report has none", () => { - expect(toCostReportEnvelope(BARE_REPORT)).toStrictEqual({ - cost_report_version: COST_REPORT_ENVELOPE_VERSION, - period: { from_day: "2026-08-17", to_day: "2026-08-17" }, - measurement_enabled: true, - sessions: 1, - totals: { requests: 1 }, - by_step: [{ attribution: "unattributed", totals: { requests: 1 } }], - by_model: [{ totals: { requests: 1 } }], - by_tool: [ - { - tool: "codex", - coverage: "covered", - capability: { - local_read: null, - export: null, - journal_attributable: false, - task_attributable: false, - }, - totals: { requests: 1 }, - }, - ], - by_project: [{ totals: { requests: 1 } }], - by_task: [{ totals: { requests: 1 } }], - by_backlog: [{ totals: { requests: 1 } }], - by_flow: [{ attribution: "unattributed", totals: { requests: 1 } }], - by_agent: [{ attribution: "not-stated", totals: { requests: 1 } }], - by_prompt: [{ totals: { requests: 1 } }], - by_day: [{ day: "2026-08-17", totals: { requests: 1 } }], - by_person: [{ resolution: "none", identities: [], totals: { requests: 1 } }], - attribution: [{ attribution: "unattributed", totals: { requests: 1 } }], - read: { undated_records: 0, unreadable_lines: 0 }, - }); - }); - - it("carries every optional field under its snake_case name when the report has them all", () => { - expect(toCostReportEnvelope(FULL_REPORT)).toStrictEqual({ - cost_report_version: COST_REPORT_ENVELOPE_VERSION, - period: { from_day: "2026-08-17", to_day: "2026-08-18" }, - measurement_enabled: false, - task: "2026_08/widgets", - filters: { project: "acme/widgets", model: "opus" }, - empty_selection: { filter: "model", value: "opus", known: true, combination: true }, - sessions: 2, - totals: FULL_TOTALS_RENDERED, - active_time_s: 754, - by_step: [{ step: "implement", attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], - by_model: [{ model: "opus", totals: FULL_TOTALS_RENDERED }], - by_tool: [ - { - tool: "copilot", - coverage: "not-covered", - reason: "A session total only.", - capability: { - local_read: null, - export: null, - journal_attributable: false, - task_attributable: false, - }, - totals: FULL_TOTALS_RENDERED, - session_totals: { requests: 0, output_tokens: 5 }, - }, - ], - by_project: [{ project: "acme/widgets", totals: FULL_TOTALS_RENDERED }], - by_task: [ - { task: "2026_08/widgets", attribution: "declared", totals: FULL_TOTALS_RENDERED }, - { reason: "no-declaration", totals: FULL_TOTALS_RENDERED }, - ], - by_backlog: [ - { backlog: "STORY-7", totals: FULL_TOTALS_RENDERED }, - { declaration: "unreadable", totals: FULL_TOTALS_RENDERED }, - { reason: "no-journal", totals: FULL_TOTALS_RENDERED }, - ], - by_flow: [ - { - flow: "aidd-orchestrator:01-sdlc", - attribution: "journal-interval", - started_at: "2026-08-17T08:00:00Z", - totals: FULL_TOTALS_RENDERED, - }, - ], - by_agent: [{ agent: "Explore", attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], - by_prompt: [ - { prompt: "p-1", started_at: "2026-08-17T09:00:00Z", totals: FULL_TOTALS_RENDERED }, - ], - by_day: [{ day: "2026-08-17", totals: FULL_TOTALS_RENDERED }], - by_person: [ - { - resolution: "mapped", - person: "ada", - display_name: "Ada L.", - identities: ["ada@example.test"], - totals: FULL_TOTALS_RENDERED, - }, - ], - attribution: [{ attribution: "tool-stated", totals: FULL_TOTALS_RENDERED }], - task_attribution: [{ attribution: "declared", totals: FULL_TOTALS_RENDERED }], - read: { undated_records: 3, unreadable_lines: 4, identity_unusable: "unreadable" }, - }); - }); -}); - -describe("the two renderings are one computation", () => { - const RECORDS: readonly TelemetrySinkRecord[] = [ - record({ - turn_id: "a", - cost_usd: 1.5, - input_tokens: 100, - output_tokens: 20, - cache_read_tokens: 880, - model: "opus", - step: "implement", - step_attribution: "tool-stated", - }), - record({ turn_id: "b", cost_usd: 0.5, input_tokens: 10, model: "haiku" }), - ]; - - it("prints the figures the object carries, from the same report value", () => { - const report = buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: RECORDS, - journals: [], - declaredTools: DECLARED, - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }); - const output = new CapturingOutput(); - printCostReport(output, report); - const text = output.lines.join("\n"); - const envelope = toCostReportEnvelope(report); - - // Every headline figure, taken from the object and looked for in the text. A second - // computation on either side would drift from the other exactly here. - const tokens = - (envelope.totals.input_tokens ?? 0) + - (envelope.totals.output_tokens ?? 0) + - (envelope.totals.cache_read_tokens ?? 0) + - (envelope.totals.cache_creation_tokens ?? 0); - expect(text).toContain(tokens.toLocaleString("en-US")); - expect(text).toContain(`$${((envelope.totals.cost_micro_usd ?? 0) / 1e6).toFixed(2)}`); - expect(text).toContain(String(envelope.sessions)); - for (const row of envelope.by_model) expect(text).toContain(row.model); - for (const row of envelope.by_step) if (row.step) expect(text).toContain(row.step); - for (const row of envelope.by_tool) if (row.reason) expect(text).toContain(row.reason); - }); - - it("takes the same value on both sides, so neither can see a figure the other cannot", () => { - const printerSource = readFileSync( - fileURLToPath( - new URL("../../../../src/presentation/display/cost-report-display.ts", import.meta.url) - ), - "utf8" - ); - const envelopeSource = readFileSync( - fileURLToPath( - new URL( - "../../../../src/contexts/telemetry/domain/cost-report-envelope.ts", - import.meta.url - ) - ), - "utf8" - ); - - // Both take a CostReport and nothing else; neither reaches for records or a sink. - for (const source of [printerSource, envelopeSource]) { - expect(source).toContain("CostReport"); - expect(source).not.toContain("TelemetrySinkRecord"); - expect(source).not.toContain("buildCostReport"); - } - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/cost-report-order.property.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-order.property.unit.test.ts deleted file mode 100644 index d9edae04c..000000000 --- a/cli/tests/contexts/telemetry/domain/cost-report-order.property.unit.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import * as fc from "fast-check"; -import { describe, expect, it } from "vitest"; -import { - buildCostReport, - type CostReportInput, - type CostReportSessionJournal, -} from "../../../../src/contexts/telemetry/domain/cost-report.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -// A re-read appends, so one session's lines sit in different orders on two machines. Mixed key -// kinds in one `Map` — `by_flow` keys on an interval object or on a skill name — leak that order. -const AT = "2026-08-18T10:00:00Z"; -const LATER = "2026-08-18T11:30:00Z"; - -const NAMES_AGENTS = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: true, agentName: true }, - export: null, - journalAttributable: true, - taskAttributable: true, -} as const; - -const NAMES_NO_AGENT = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -const DECLARED = [ - { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, - { tool: "codex", coverage: "covered", capability: NAMES_NO_AGENT }, -] as const; - -/** One session the journal witnessed, so an interval-derived flow row exists beside a - * tool-stated one — the two key kinds this property is about. */ -const JOURNALS: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-witnessed", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-18T09:00:00Z"), - endMs: Date.parse("2026-08-18T10:30:00Z"), - closedBy: "boundary", - }, - ], - }, -]; - -function record(overrides: Partial): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-witnessed", - vendor_field: "sessionId", - step_attribution: "unattributed", - event_timestamp: AT, - cost_usd: 1, - ...overrides, - }; -} - -/** Every row kind the report can produce, once each — including a pair with identical figures - * whose order only a tie-break on the row's own key can decide. */ -const RECORDS: readonly TelemetrySinkRecord[] = [ - // Inside the witnessed flow, agent named by the tool. - record({ turn_id: "a", agent_name: "aidd-dev:executor", model: "opus", prompt_id: "p-1" }), - // Inside the witnessed flow, no agent — the main thread, since claude names agents. - record({ turn_id: "b", model: "haiku", prompt_id: "p-1" }), - // Outside every interval, but the tool named an orchestrating skill: a tool-stated flow. - record({ - turn_id: "c", - vendor_id: "s-unwitnessed", - event_timestamp: LATER, - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - model: "opus", - }), - record({ - turn_id: "d", - vendor_id: "s-unwitnessed", - event_timestamp: LATER, - step_attribution: "tool-stated", - step: "aidd-orchestrator:02-backlog", - model: "haiku", - }), - // A tool that never names an agent: the third agent row, and it must not read as a main - // thread however the records arrive. - record({ turn_id: "e", tool: "codex", vendor_id: "s-codex", event_timestamp: LATER }), - // Two rows with identical figures, so only the tie-break on the row's own key can order - // them — the case repetition alone never catches. - record({ turn_id: "f", model: "zulu", cost_usd: 3, prompt_id: "p-2" }), - record({ turn_id: "g", model: "alpha", cost_usd: 3, prompt_id: "p-3" }), - // One billed call two routes saw, with equal counters and different content: picking - // `group[0]` would make the survivor depend on which line the day file listed first. - record({ - turn_id: "h", - billed_request_id: "req-1", - model: "opus", - cost_usd: 4, - input_tokens: 10, - agent_name: "Explore", - }), - record({ - turn_id: "i", - billed_request_id: "req-1", - model: "opus", - cost_usd: 4, - input_tokens: 10, - prompt_id: "p-4", - }), -]; - -function reportOf(records: readonly TelemetrySinkRecord[]): string { - const input: CostReportInput = { - fromDay: "2026-08-17", - toDay: "2026-08-21", - records, - journals: JOURNALS, - declaredTools: DECLARED, - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }; - return JSON.stringify(buildCostReport(input)); -} - -describe("buildCostReport — every row kind, arriving in any order", () => { - it("answers the same report for every permutation of the same records", () => { - const expected = reportOf(RECORDS); - - fc.assert( - fc.property(fc.shuffledSubarray([...RECORDS], { minLength: RECORDS.length }), (shuffled) => { - expect(reportOf(shuffled)).toBe(expected); - }), - { numRuns: 300 } - ); - }); - - // The fixture has to actually exercise what the property is about: a permutation of records - // that produce only one kind of row proves nothing about mixed keys. - it("covers both flow row kinds and all three agent attributions", () => { - const report = JSON.parse(reportOf(RECORDS)) as { - byFlows: { attribution: string }[]; - byAgents: { attribution: string }[]; - }; - - expect(new Set(report.byFlows.map((row) => row.attribution))).toEqual( - new Set(["journal-interval", "tool-stated", "unattributed"]) - ); - expect(new Set(report.byAgents.map((row) => row.attribution))).toEqual( - new Set(["tool-stated", "main-thread", "not-stated"]) - ); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/cost-report-person.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-person.unit.test.ts deleted file mode 100644 index 04f26785a..000000000 --- a/cli/tests/contexts/telemetry/domain/cost-report-person.unit.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildCostReport, - type CostReportInput, - type CostTotals, - toMicroUsd, -} from "../../../../src/contexts/telemetry/domain/cost-report.js"; -import { - COST_REPORT_ENVELOPE_VERSION, - toCostReportEnvelope, -} from "../../../../src/contexts/telemetry/domain/cost-report-envelope.js"; -import type { PersonIdentity } from "../../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const NO_CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function request(overrides: Partial = {}): TelemetrySinkRecord { - return { ...BASE, ...overrides }; -} - -function report(overrides: Partial = {}) { - return buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }); -} - -function twoIdentitiesOnePerson(): PersonIdentity { - return { personId: "person-a", origin: "adopted", alsoMe: ["machine-1", "machine-2"] }; -} - -function sumOf(rows: readonly { readonly totals: CostTotals }[]): number { - return rows.reduce((sum, row) => sum + row.totals.requests, 0); -} - -describe("byPeople — one raw identity resolved per group, never merged or dropped", () => { - it("two identifiers one person declared produce one row, not two", () => { - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [ - request({ turn_id: "a", person_id: "machine-1" }), - request({ turn_id: "b", person_id: "machine-2" }), - ], - }); - - const mappedRows = built.byPeople.filter((row) => row.resolution === "mapped"); - expect(mappedRows).toHaveLength(1); - expect(mappedRows[0]?.person).toBe("person-a"); - }); - - it("a mapped row names every raw identity behind it", () => { - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [ - request({ turn_id: "a", person_id: "machine-1" }), - request({ turn_id: "b", person_id: "machine-2" }), - ], - }); - - const mapped = built.byPeople.find((row) => row.resolution === "mapped"); - expect(mapped?.identities).toEqual( - expect.arrayContaining(["person-a", "machine-1", "machine-2"]) - ); - }); - - it("an identity nobody declared gets its own row, labelled unresolved", () => { - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [request({ turn_id: "a", person_id: "a-stranger" })], - }); - - const unresolved = built.byPeople.filter((row) => row.resolution === "unresolved"); - expect(unresolved).toHaveLength(1); - expect(unresolved[0]?.identities).toEqual(["a-stranger"]); - }); - - it("two identifiers nobody declared produce two rows, never one merged bucket", () => { - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [ - request({ turn_id: "a", person_id: "a-stranger" }), - request({ turn_id: "b", person_id: "another-stranger" }), - ], - }); - - const unresolved = built.byPeople.filter((row) => row.resolution === "unresolved"); - expect(unresolved).toHaveLength(2); - expect(unresolved.map((row) => row.identities[0]).sort()).toEqual([ - "a-stranger", - "another-stranger", - ]); - }); - - // A record that carried no identifier is this machine's own person: the sink has one writer. - // Kept apart from `mapped`, which is a record naming a person this identity claims. - it("a record with no identifier lands in this machine's own row, distinct from every unresolved one", () => { - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [request({ turn_id: "a", person_id: "a-stranger" }), request({ turn_id: "b" })], - }); - - const thisMachine = built.byPeople.filter((row) => row.resolution === "this-machine"); - expect(thisMachine).toHaveLength(1); - expect(thisMachine[0]?.person).toBe("person-a"); - const unresolved = built.byPeople.filter((row) => row.resolution === "unresolved"); - expect(unresolved).toHaveLength(1); - expect(built.byPeople.filter((row) => row.resolution === "none")).toHaveLength(0); - }); - - // The other half of the same contract: with nothing declared, nobody opted in, and the - // report says exactly that rather than naming a person no file names. - it("a record with no identifier stays a no-identifier row when no identity was declared", () => { - const built = report({ records: [request({ turn_id: "b" })] }); - - const none = built.byPeople.filter((row) => row.resolution === "none"); - expect(none).toHaveLength(1); - expect(none[0]?.person).toBeUndefined(); - }); - - it("summing every person row's requests equals the report's own total", () => { - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [ - request({ turn_id: "a", person_id: "machine-1" }), - request({ turn_id: "b", person_id: "machine-2" }), - request({ turn_id: "c", person_id: "a-stranger" }), - request({ turn_id: "d" }), - ], - }); - - expect(sumOf(built.byPeople)).toBe(built.totals.requests); - expect(built.totals.requests).toBe(4); - }); - - it("summing every person row's own money and tokens equals the report's own total", () => { - const tokenFields = { - input_tokens: 100, - output_tokens: 40, - cache_read_tokens: 15, - cache_creation_tokens: 5, - }; - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [ - request({ turn_id: "a", person_id: "machine-1", cost_usd: 0.5, ...tokenFields }), - request({ turn_id: "b", person_id: "machine-2", cost_usd: 0.25, ...tokenFields }), - request({ turn_id: "c", person_id: "a-stranger", cost_usd: 0.1, ...tokenFields }), - request({ turn_id: "d", cost_usd: 0.05, ...tokenFields }), - ], - }); - - const summedCost = built.byPeople.reduce((sum, row) => sum + (row.totals.costMicroUsd ?? 0), 0); - const summedInputTokens = built.byPeople.reduce( - (sum, row) => sum + (row.totals.inputTokens ?? 0), - 0 - ); - const summedOutputTokens = built.byPeople.reduce( - (sum, row) => sum + (row.totals.outputTokens ?? 0), - 0 - ); - const summedCacheReadTokens = built.byPeople.reduce( - (sum, row) => sum + (row.totals.cacheReadTokens ?? 0), - 0 - ); - const summedCacheCreationTokens = built.byPeople.reduce( - (sum, row) => sum + (row.totals.cacheCreationTokens ?? 0), - 0 - ); - expect(summedCost).toBe(built.totals.costMicroUsd); - expect(summedInputTokens).toBe(built.totals.inputTokens); - expect(summedOutputTokens).toBe(built.totals.outputTokens); - expect(summedCacheReadTokens).toBe(built.totals.cacheReadTokens); - expect(summedCacheCreationTokens).toBe(built.totals.cacheCreationTokens); - expect(built.totals.costMicroUsd).toBe(toMicroUsd(0.9)); - expect(built.totals.inputTokens).toBe(400); - expect(built.totals.outputTokens).toBe(160); - expect(built.totals.cacheReadTokens).toBe(60); - expect(built.totals.cacheCreationTokens).toBe(20); - }); - - // A filter per group drops whatever it does not name, which is how `this-machine` rows went - // missing from this breakdown while the totals they belonged to stayed. - it("orders mapped rows first, then this machine's own, then unresolved, then no identifier", () => { - const withIdentity = report({ - identity: twoIdentitiesOnePerson(), - records: [ - request({ turn_id: "a", person_id: "machine-1" }), - request({ turn_id: "b", person_id: "a-stranger" }), - request({ turn_id: "c" }), - ], - }); - const withoutIdentity = report({ records: [request({ turn_id: "c" })] }); - - expect(withIdentity.byPeople.map((row) => row.resolution)).toEqual([ - "mapped", - "this-machine", - "unresolved", - ]); - expect(withoutIdentity.byPeople.map((row) => row.resolution)).toEqual(["none"]); - }); - - // The determinism claim itself, stated as a test: the same sink and the same identity - // answer the same way whether or not the records were stamped when they were stored. - it("answers the same whether a record was stamped at store time or named at report time", () => { - const stamped = report({ - identity: twoIdentitiesOnePerson(), - records: [request({ turn_id: "a", person_id: "person-a" })], - }); - const unstamped = report({ - identity: twoIdentitiesOnePerson(), - records: [request({ turn_id: "a" })], - }); - - expect(unstamped.byPeople[0]?.person).toBe(stamped.byPeople[0]?.person); - expect(unstamped.byPeople[0]?.totals.requests).toBe(stamped.byPeople[0]?.totals.requests); - }); -}); - -describe("byPeople — no mapping declared at all", () => { - it("resolves every identifier as unresolved, and leaves the figures unchanged", () => { - const withMapping = report({ - identity: twoIdentitiesOnePerson(), - records: [request({ turn_id: "a", person_id: "machine-1" })], - }); - const withoutMapping = report({ - records: [request({ turn_id: "a", person_id: "machine-1" })], - }); - - expect(withoutMapping.byPeople.every((row) => row.resolution !== "mapped")).toBe(true); - expect( - withoutMapping.byPeople.find((row) => row.identities.includes("machine-1"))?.resolution - ).toBe("unresolved"); - expect(withoutMapping.totals).toEqual(withMapping.totals); - }); - - it("identityUnusableCause is absent when nothing said otherwise", () => { - expect(report().identityUnusableCause).toBeUndefined(); - }); - - it("carries identityUnusableCause through when the caller states it", () => { - expect(report({ identityUnusableCause: "unreadable" }).identityUnusableCause).toBe( - "unreadable" - ); - expect(report({ identityUnusableCause: "absent" }).identityUnusableCause).toBe("absent"); - }); -}); - -describe("the envelope carries by_person for a program to parse", () => { - it("carries the person rows, their identities, and a raised report version", () => { - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [ - request({ turn_id: "a", person_id: "machine-1" }), - request({ turn_id: "b", person_id: "a-stranger" }), - ], - }); - const envelope = toCostReportEnvelope(built); - - expect(envelope.cost_report_version).toBe(COST_REPORT_ENVELOPE_VERSION); - expect(envelope.by_person.length).toBeGreaterThan(0); - const mapped = envelope.by_person.find((row) => row.resolution === "mapped"); - expect(mapped?.identities).toEqual(expect.arrayContaining(["machine-1"])); - expect(envelope.read.identity_unusable).toBeUndefined(); - }); -}); - -describe("byPeople — a billed call seen by both routes keeps its person", () => { - // The export route never carries a person, so the survivor `mergeBilledRequestGroup` picks - // by `cost_usd` is the one sibling in the group with no person_id at all. - it("backfills the local-read sibling's person_id onto the export-route survivor", () => { - const built = report({ - identity: twoIdentitiesOnePerson(), - records: [ - request({ - billed_request_id: "call-1", - provenance: "export", - cost_usd: 1, - input_tokens: 10, - // No person_id: the export route's own contract. - }), - request({ - billed_request_id: "call-1", - provenance: "local-read", - person_id: "machine-1", - // No cost_usd: no local reader has ever captured one - never the survivor by - // `mergeBilledRequestGroup`'s own cost-bearing rule. - }), - ], - }); - - expect(built.totals.requests).toBe(1); - const mapped = built.byPeople.find((row) => row.resolution === "mapped"); - expect(mapped?.person).toBe("person-a"); - expect(mapped?.totals.requests).toBe(1); - expect(built.byPeople.some((row) => row.resolution === "none")).toBe(false); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/cost-report-task.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-task.unit.test.ts deleted file mode 100644 index 2c8f35319..000000000 --- a/cli/tests/contexts/telemetry/domain/cost-report-task.unit.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildCostReport, - type CostReportInput, - type CostReportSessionJournal, - type CostTotals, - toMicroUsd, -} from "../../../../src/contexts/telemetry/domain/cost-report.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function request(overrides: Partial = {}): TelemetrySinkRecord { - return { ...BASE, ...overrides }; -} - -const NO_CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -function report(overrides: Partial = {}) { - return buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }); -} - -function sumOf(rows: readonly { readonly totals: CostTotals }[]): CostTotals { - return rows.reduce( - (accumulator, row) => ({ - requests: accumulator.requests + row.totals.requests, - costMicroUsd: (accumulator.costMicroUsd ?? 0) + (row.totals.costMicroUsd ?? 0), - }), - { requests: 0, costMicroUsd: 0 } - ); -} - -// One session that declares two tasks in sequence, closing the first the moment the -// second opens. A record before the first declaration falls in neither. -const FIRST_TASK = "2026_08/first-task"; -const SECOND_TASK = "2026_08/second-task"; -const JOURNALS: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-two-tasks", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [ - { - path: "aidd_docs/tasks/2026_08/first-task/spec.md", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - }, - { - path: "aidd_docs/tasks/2026_08/second-task/spec.md", - startMs: Date.parse("2026-08-17T11:00:00Z"), - endMs: Date.parse("2026-08-17T12:00:00Z"), - }, - ], - flowIntervals: [], - }, -]; -const RECORDS: readonly TelemetrySinkRecord[] = [ - // Before any declaration - belongs to no task. - request({ - vendor_id: "s-two-tasks", - turn_id: "before", - cost_usd: 1, - event_timestamp: "2026-08-17T09:00:00Z", - }), - request({ - vendor_id: "s-two-tasks", - turn_id: "first", - cost_usd: 2, - event_timestamp: "2026-08-17T10:30:00Z", - }), - request({ - vendor_id: "s-two-tasks", - turn_id: "second", - cost_usd: 4, - event_timestamp: "2026-08-17T11:30:00Z", - }), -]; - -describe("buildCostReport — by_task groups by the declared interval a record falls in", () => { - it("gives one row per task declared in the period", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - - const named = built.byTasks.filter((row) => row.task !== undefined); - expect(named.map((row) => row.task)).toEqual([SECOND_TASK, FIRST_TASK]); - }); - - it("carries the attribution a closed interval always rests on", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - - for (const row of built.byTasks.filter((row) => row.task !== undefined)) { - expect(row.attribution).toBe("declared"); - } - }); - - it("places a record before any declaration in its own row, never dropped", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - - const noTask = built.byTasks.find((row) => row.task === undefined); - expect(noTask).toBeDefined(); - expect(noTask?.totals.requests).toBe(1); - expect(noTask?.totals.costMicroUsd).toBe(toMicroUsd(1)); - expect(noTask?.attribution).toBeUndefined(); - }); - - it("counts each record once, in exactly one row, when a session declares twice", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - - expect(sumOf(built.byTasks).requests).toBe(RECORDS.length); - }); - - it("sums the task rows back to the same period total as every other breakdown", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - const expected = { requests: built.totals.requests, costMicroUsd: built.totals.costMicroUsd }; - - expect(sumOf(built.byTasks)).toEqual(expected); - expect(sumOf(built.byModels)).toEqual(expected); - expect(sumOf(built.byProjects)).toEqual(expected); - }); - - it("sorts named tasks largest first, with the no-task row placed last regardless of size", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - - expect(built.byTasks.map((row) => row.task)).toEqual([SECOND_TASK, FIRST_TASK, undefined]); - }); - - it("holds everything in the no-task row when nothing was ever declared, and still reconciles", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-silent", - tool: "codex", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-silent", cost_usd: 5, event_timestamp: "2026-08-17T10:00:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byTasks).toHaveLength(1); - expect(built.byTasks[0]?.task).toBeUndefined(); - expect(built.byTasks[0]?.totals.requests).toBe(1); - expect(sumOf(built.byTasks).requests).toBe(built.totals.requests); - }); - - // A session whose journal was read and declared nothing is a fact about the work; a - // session with no journal at all is a fact about the read. One name for both is a zero. - it("says no journal was read, rather than that the session declared nothing", () => { - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-unjournalled", event_timestamp: "2026-08-17T10:00:00Z" }), - ]; - - const built = report({ records, journals: [] }); - - expect(built.byTasks).toHaveLength(1); - expect(built.byTasks[0]?.task).toBeUndefined(); - expect(built.byTasks[0]?.reason).toBe("no-journal"); - expect(sumOf(built.byTasks).requests).toBe(built.totals.requests); - }); - - it("still says the session declared nothing when its journal was read and held no declaration", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-read-but-silent", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-read-but-silent", event_timestamp: "2026-08-17T10:00:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byTasks[0]?.reason).toBe("no-declaration"); - }); - - // The backlog axis keys off `by_task`'s own row key, so an unread journal must not arrive - // there as a task that declared no backlog item either. - it("carries the unread journal through to the backlog axis unchanged", () => { - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-unjournalled", event_timestamp: "2026-08-17T10:00:00Z" }), - ]; - - const built = report({ records, journals: [] }); - - expect(built.byBacklog).toHaveLength(1); - expect(built.byBacklog[0]?.reason).toBe("no-journal"); - }); - - // A session bills records before its flow names a ticket. Where it wrote into exactly one - // task folder those have an answer, marked `inferred`, never merged into the declared row. - it("names a record no declaration covers after the only task folder the session wrote into", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-one-folder", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/first-task/plan.md"], - taskIntervals: [ - { - path: "aidd_docs/tasks/2026_08/first-task/plan.md", - startMs: Date.parse("2026-08-17T11:00:00Z"), - endMs: Date.parse("2026-08-17T12:00:00Z"), - }, - ], - flowIntervals: [], - witnessed: { - fromMs: Date.parse("2026-08-17T09:00:00Z"), - toMs: Date.parse("2026-08-17T12:00:00Z"), - }, - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-one-folder", event_timestamp: "2026-08-17T10:00:00Z" }), - request({ vendor_id: "s-one-folder", event_timestamp: "2026-08-17T11:30:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byTasks).toEqual([ - { task: FIRST_TASK, attribution: "declared", totals: expect.anything() }, - { task: FIRST_TASK, attribution: "inferred", totals: expect.anything() }, - ]); - expect(sumOf(built.byTasks).requests).toBe(built.totals.requests); - }); - - // Two candidates and no reason to choose between them: one session placed under two task - // rows at once is answered by refusing, never by picking the first. - it("infers nothing for a session that wrote into two task folders", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-two-folders", - tool: "claude-code", - writtenPaths: [ - "aidd_docs/tasks/2026_08/first-task/plan.md", - "aidd_docs/tasks/2026_08/second-task/plan.md", - ], - taskIntervals: [], - flowIntervals: [], - witnessed: { - fromMs: Date.parse("2026-08-17T09:00:00Z"), - toMs: Date.parse("2026-08-17T12:00:00Z"), - }, - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-two-folders", event_timestamp: "2026-08-17T10:00:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byTasks).toHaveLength(1); - expect(built.byTasks[0]?.task).toBeUndefined(); - expect(built.byTasks[0]?.reason).toBe("no-declaration"); - }); - - // A session whose journal was lost and recreated witnesses only the time since: its earlier - // records are in the sink, and attributing them to a folder it touched today is false. - it("infers nothing for a record outside the span its journal actually witnessed", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-short-journal", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/first-task/plan.md"], - taskIntervals: [], - flowIntervals: [], - witnessed: { - fromMs: Date.parse("2026-08-21T09:00:00Z"), - toMs: Date.parse("2026-08-21T12:00:00Z"), - }, - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-short-journal", event_timestamp: "2026-08-17T10:00:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byTasks).toHaveLength(1); - expect(built.byTasks[0]?.task).toBeUndefined(); - expect(built.byTasks[0]?.reason).toBe("precedes-journal"); - }); - - // A resumed transcript hands over turns billed days before its journal opened, and the same - // session then declares a task: both records are unattributed, for two different reasons. - it("separates a record older than its journal from one that merely preceded a declaration", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-resumed", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [ - { - path: "aidd_docs/tasks/2026_08/first-task/plan.md", - startMs: Date.parse("2026-08-21T10:00:00Z"), - endMs: Date.parse("2026-08-21T12:00:00Z"), - }, - ], - flowIntervals: [], - witnessed: { - fromMs: Date.parse("2026-08-21T09:00:00Z"), - toMs: Date.parse("2026-08-21T12:00:00Z"), - }, - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-resumed", event_timestamp: "2026-08-17T10:00:00Z" }), - request({ vendor_id: "s-resumed", event_timestamp: "2026-08-21T09:30:00Z" }), - request({ vendor_id: "s-resumed", event_timestamp: "2026-08-21T11:00:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byTasks).toEqual([ - { task: FIRST_TASK, attribution: "declared", totals: expect.anything() }, - { reason: "precedes-journal", totals: expect.anything() }, - { reason: "precedes-declaration", totals: expect.anything() }, - ]); - expect(sumOf(built.byTasks).requests).toBe(built.totals.requests); - }); - - it("never lets the whole-session written-path inference the --task filter uses leak into this breakdown", () => { - // A session that wrote into a task folder but never declared - this breakdown does not - // consult written paths at all, so the record lands in the no-task row. - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-written-only", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/first-task/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-written-only", - cost_usd: 3, - event_timestamp: "2026-08-17T10:00:00Z", - }), - ]; - - const built = report({ records, journals }); - - expect(built.byTasks).toHaveLength(1); - expect(built.byTasks[0]?.task).toBeUndefined(); - }); - - it("never contradicts a --task header: the inferred route's own record still names no declared interval", () => { - // The --task filter's own "inferred" route keeps this record in scope, but by_task does - // not read that route: its no-`task` row is never "this session touched no task". - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-inferred-only", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/first-task/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-inferred-only", - cost_usd: 6, - event_timestamp: "2026-08-17T10:00:00Z", - }), - ]; - - const built = report({ records, journals, task: "2026_08/first-task" }); - - expect(built.task).toBe("2026_08/first-task"); - expect(built.totals.requests).toBe(1); - expect(built.byTasks).toHaveLength(1); - expect(built.byTasks[0]?.task).toBeUndefined(); - expect(built.byTasks[0]?.totals.requests).toBe(1); - expect(sumOf(built.byTasks).requests).toBe(built.totals.requests); - }); - - it("resolves a declared interval whose path merely contains '..' as text, never misreading live coverage as journal-silent", () => { - // A declared path may hold ".." as text - "2026_02_10_a..b" is a name, not a climb - and a - // blanket substring check falls through to `journal-silent` for a live, open interval. - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-dotted-name", - tool: "codex", - writtenPaths: [], - taskIntervals: [ - { - path: "aidd_docs/tasks/2026_02/2026_02_10_a..b/spec.md", - startMs: Date.parse("2026-02-10T09:10:00Z"), - endMs: Date.parse("2026-02-10T09:40:00Z"), - }, - ], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-dotted-name", - cost_usd: 2, - event_timestamp: "2026-02-10T09:20:00Z", - }), - ]; - - const built = report({ records, journals }); - - expect(built.byTasks).toHaveLength(1); - expect(built.byTasks[0]?.task).toBe("2026_02/2026_02_10_a..b"); - expect(built.byTasks[0]?.reason).toBeUndefined(); - }); - - it("answers all six questions over one period, each reconciling to the same total", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - const expected = { requests: built.totals.requests, costMicroUsd: built.totals.costMicroUsd }; - - expect(sumOf(built.byModels)).toEqual(expected); - expect(sumOf(built.byTasks)).toEqual(expected); - expect(sumOf(built.bySteps)).toEqual(expected); - expect(sumOf(built.byPeople)).toEqual(expected); - expect(sumOf(built.byProjects)).toEqual(expected); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts deleted file mode 100644 index 71edf7b29..000000000 --- a/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts +++ /dev/null @@ -1,1840 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - buildCostReport, - type CostReportInput, - type CostReportSessionJournal, - type CostTotals, - TotalsAccumulator, - toMicroUsd, -} from "../../../../src/contexts/telemetry/domain/cost-report.js"; -import { - parseTelemetrySinkLine, - type TelemetrySinkRecord, -} from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { AI_TOOL_IDS } from "../../../../src/kernel/tool.js"; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function request(overrides: Partial = {}): TelemetrySinkRecord { - return { ...BASE, ...overrides }; -} - -function sessionMeasure(overrides: Partial = {}): TelemetrySinkRecord { - return { ...BASE, kind: "session", ...overrides }; -} - -/** The minimum the type requires; what a tool can really supply is not what these tests - * are about. */ -const NO_CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -/** A tool whose local read does name the agent that ran - what tells "the main thread" - * apart from "a tool that could never have said". */ -const NAMES_AGENTS = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: true }, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -/** Codex's real shape: a declared local read that supplies token counters and names no - * agent. Only a route that says `agentName` can support a main thread. */ -const READS_BUT_NAMES_NO_AGENT = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -const TOOL_THAT_NAMES_AGENTS = [ - { tool: "claude" as const, coverage: "covered" as const, capability: NAMES_AGENTS }, -]; - -function report(overrides: Partial = {}) { - return buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }); -} - -function sumOf(rows: readonly { readonly totals: CostTotals }[]): CostTotals { - return rows.reduce( - (accumulator, row) => ({ - requests: accumulator.requests + row.totals.requests, - costMicroUsd: (accumulator.costMicroUsd ?? 0) + (row.totals.costMicroUsd ?? 0), - inputTokens: (accumulator.inputTokens ?? 0) + (row.totals.inputTokens ?? 0), - outputTokens: (accumulator.outputTokens ?? 0) + (row.totals.outputTokens ?? 0), - }), - { requests: 0, costMicroUsd: 0, inputTokens: 0, outputTokens: 0 } - ); -} - -describe("buildCostReport — the two kinds are never summed", () => { - it("takes money and tokens from request records alone", () => { - // The same session's cost, present on both kinds. The metric line is one flush window's - // own delta; adding it to the request lines counts part of the session twice. - const built = report({ - records: [ - request({ cost_usd: 0.16, input_tokens: 100, output_tokens: 10 }), - sessionMeasure({ cost_usd: 0.0151, input_tokens: 7 }), - ], - }); - - expect(built.totals.costMicroUsd).toBe(toMicroUsd(0.16)); - expect(built.totals.inputTokens).toBe(100); - expect(built.totals.requests).toBe(1); - }); - - it("takes active time from session records alone, and never breaks it down by step", () => { - const built = report({ - records: [ - request({ step: "aidd-dev:02-implement", step_attribution: "tool-stated", cost_usd: 1 }), - sessionMeasure({ active_time_s: 47 }), - ], - }); - - expect(built.activeTimeSeconds).toBe(47); - // No active-time measure on any tool carries a step attribute, so a per-step share - // could only ever be cost. The step rows carry no time field at all. - expect(JSON.stringify(built.bySteps)).not.toContain("active"); - }); - - it("reports no active time at all, rather than zero, when no record carried it", () => { - expect(report({ records: [request({ cost_usd: 1 })] }).activeTimeSeconds).toBeUndefined(); - }); -}); - -// Both routes stored the same three billed calls: the export half carries the money, the -// local half a tool-stated step, and both carry the same `billed_request_id`. -describe("buildCostReport — one billed call, seen by both routes, counts once", () => { - function exportedApiRequests(): readonly TelemetrySinkRecord[] { - return [ - { - sink_schema_version: 2, - kind: "request", - provenance: "export", - tool: "claude", - vendor_id: "7c53f826-fc3e-4729-8e2b-2cba887d3926", - vendor_field: "session.id", - turn_id: "a4b7b0b6-dc16-4889-b25a-def1d207aec9", - turn_field: "prompt.id", - step_attribution: "unattributed", - project_id: "aidd-lab/telemetry-proof", - billed_request_id: "req_011CeAaRe8Mm7oS7xvfjDPw8", - cost_usd: 0.013220099999999999, - input_tokens: 2, - output_tokens: 4, - cache_read_tokens: 43847, - cache_creation_tokens: 0, - model: "claude-sonnet-5", - event_timestamp: "2026-08-18T17:04:39.258Z", - }, - { - sink_schema_version: 2, - kind: "request", - provenance: "export", - tool: "claude", - vendor_id: "22177147-d8cb-4ee1-976f-0ef82bd62491", - vendor_field: "session.id", - turn_id: "a7294fac-94af-4c32-b02d-d4c9a6d6edaa", - turn_field: "prompt.id", - step_attribution: "unattributed", - billed_request_id: "req_011CeBjuapGBsHnPVLStybgB", - cost_usd: 0.10862279999999999, - input_tokens: 2, - output_tokens: 157, - cache_read_tokens: 27506, - cache_creation_tokens: 16335, - model: "claude-sonnet-5", - event_timestamp: "2026-08-19T07:49:37.014Z", - }, - { - sink_schema_version: 2, - kind: "request", - provenance: "export", - tool: "claude", - vendor_id: "22177147-d8cb-4ee1-976f-0ef82bd62491", - vendor_field: "session.id", - turn_id: "a7294fac-94af-4c32-b02d-d4c9a6d6edaa", - turn_field: "prompt.id", - step_attribution: "unattributed", - billed_request_id: "req_011CeBjux2xeafVaiUX646Qz", - cost_usd: 0.05190105, - input_tokens: 2, - output_tokens: 4, - cache_read_tokens: 14096, - cache_creation_tokens: 12695, - model: "claude-sonnet-5", - event_timestamp: "2026-08-19T07:49:40.427Z", - }, - ]; - } - - function localCounterpartOf(exported: TelemetrySinkRecord): TelemetrySinkRecord { - return { - sink_schema_version: exported.sink_schema_version, - kind: "request", - provenance: "local-read", - tool: exported.tool, - vendor_id: exported.vendor_id, - vendor_field: "sessionId", - turn_id: exported.billed_request_id, - turn_field: "requestId", - billed_request_id: exported.billed_request_id, - step_attribution: "tool-stated", - step: "aidd-dev:02-implement", - input_tokens: exported.input_tokens, - output_tokens: exported.output_tokens, - cache_read_tokens: exported.cache_read_tokens, - cache_creation_tokens: exported.cache_creation_tokens, - model: exported.model, - event_timestamp: exported.event_timestamp, - }; - } - - it("sums a naive union of both routes' records to double — the reproduced defect", () => { - const exported = exportedApiRequests(); - expect(exported).toHaveLength(3); - expect(exported.every((r) => r.billed_request_id !== undefined)).toBe(true); - const local = exported.map(localCounterpartOf); - - // What a naive reader gets by concatenating every route's records with no collapse: - // six request lines for three real billed calls — money and tokens read double. - const naiveUnion = [...exported, ...local]; - expect(naiveUnion).toHaveLength(6); - const naiveInputTokens = naiveUnion.reduce((sum, r) => sum + (r.input_tokens ?? 0), 0); - expect(naiveInputTokens).toBe(12); - }); - - it("collapses the two routes' records for the same call into one, in the built report", () => { - const exported = exportedApiRequests(); - const local = exported.map(localCounterpartOf); - const trueCostMicroUsd = exported.reduce((sum, r) => sum + toMicroUsd(r.cost_usd ?? 0), 0); - const trueInputTokens = exported.reduce((sum, r) => sum + (r.input_tokens ?? 0), 0); - const trueOutputTokens = exported.reduce((sum, r) => sum + (r.output_tokens ?? 0), 0); - const trueCacheReadTokens = exported.reduce((sum, r) => sum + (r.cache_read_tokens ?? 0), 0); - - const union = [...exported, ...local]; - const built = report({ records: union }); - - // One billed call, counted once, whichever route or routes saw it — never the naive - // union's six, and never the true figures doubled. - expect(built.totals.requests).toBe(3); - expect(trueInputTokens).toBe(6); - expect(built.totals.costMicroUsd).toBe(trueCostMicroUsd); - expect(built.totals.inputTokens).toBe(trueInputTokens); - expect(built.totals.outputTokens).toBe(trueOutputTokens); - expect(built.totals.cacheReadTokens).toBe(trueCacheReadTokens); - - // Neither route's own strength is thrown away for the other's: the export's money - // survives, and so does the local read's tool-stated step. - const toolStated = built.attributionMix.find((row) => row.attribution === "tool-stated"); - expect(toolStated?.totals.requests).toBe(3); - expect(toolStated?.totals.costMicroUsd).toBe(trueCostMicroUsd); - - // Order-independent: a re-read's line order is never something a consumer controls, - // and neither is which of two duplicate deliveries for one billed call arrives first. - const reversed = report({ records: [...union].reverse() }); - expect(JSON.stringify(reversed)).toBe(JSON.stringify(built)); - }); -}); - -// A Codex turn read while it was still open, then read again once more of it had arrived: -// two local-read request lines sharing one turn_id, neither an edit of the other. -describe("buildCostReport — a still-open local-read turn is superseded, never doubled", () => { - const partial = request({ - turn_id: "turn-1", - input_tokens: 2816, - output_tokens: 1401, - cache_read_tokens: 48896, - cache_creation_tokens: 0, - }); - const complete = request({ - turn_id: "turn-1", - input_tokens: 5032, - output_tokens: 3550, - cache_read_tokens: 99840, - cache_creation_tokens: 0, - }); - - it("keeps the larger reading, and does not sum the two into a figure neither reported", () => { - const built = report({ records: [partial, complete] }); - - // One request, not two — a naive union would double-count a turn read twice. - expect(built.totals.requests).toBe(1); - // The complete reading's own figures, never partial + complete summed (which would - // read 7848/4951/148736/0 — a combination the tool's file never actually reported). - expect(built.totals.inputTokens).toBe(5032); - expect(built.totals.outputTokens).toBe(3550); - expect(built.totals.cacheReadTokens).toBe(99840); - }); - - it("answers the same whichever order the two readings arrive in", () => { - const forward = report({ records: [partial, complete] }); - const backward = report({ records: [complete, partial] }); - - expect(JSON.stringify(backward)).toBe(JSON.stringify(forward)); - }); - - it("never lets a smaller reading of the same turn win, in either arrival order", () => { - const built = report({ records: [complete, partial] }); - - expect(built.totals.cacheReadTokens).toBe(99840); - expect(built.totals.outputTokens).toBe(3550); - }); - - it("never collapses the export route's own turn_id, which several billed calls share", () => { - // prompt.id-shaped: a main-agent request and a subagent request under one turn_id, - // each its own billed call — collapsing here would merge two real calls into one. - const mainAgent = request({ - provenance: "export", - turn_id: "prompt-1", - input_tokens: 100, - output_tokens: 10, - }); - const subagent = request({ - provenance: "export", - turn_id: "prompt-1", - input_tokens: 40, - output_tokens: 5, - }); - - const built = report({ records: [mainAgent, subagent] }); - - expect(built.totals.requests).toBe(2); - expect(built.totals.inputTokens).toBe(140); - expect(built.totals.outputTokens).toBe(15); - }); - - it("never collapses a kind: 'session' record sharing a turn_id (Copilot's shutdown total)", () => { - // Copilot's own session-kind record is keyed on the shutdown event's own id, matched - // like any turn_id — but it is a one-shot cumulative total, never a corrigible turn. - const first = sessionMeasure({ turn_id: "shutdown-1", cache_read_tokens: 5 }); - const second = sessionMeasure({ turn_id: "shutdown-1", cache_read_tokens: 7 }); - - const built = report({ records: [first, second] }); - - const claude = built.byTools.find((row) => row.tool === "claude"); - // Both counted, exactly as any two `kind: "session"` datapoints would be — proof - // nothing here diverted them into the turn-supersede path. - expect(claude?.sessionTotals?.cacheReadTokens).toBe(12); - }); - - it("prefers an observed zero over an unmentioned counter when two readings tie on weight", () => { - // Two readings of one turn tie on `counterWeight`, since a zero weighs nothing either - // way; `model` is set so the plain sort favours the reading the tie-break must reject. - const missesTheCounter = request({ - turn_id: "turn-2", - model: "aaa", - input_tokens: 100, - output_tokens: 10, - cache_read_tokens: 5, - // cache_creation_tokens intentionally absent — never observed by this reading. - }); - const statesItAsZero = request({ - turn_id: "turn-2", - model: "zzz", - input_tokens: 100, - output_tokens: 10, - cache_read_tokens: 5, - cache_creation_tokens: 0, - }); - - const built = report({ records: [missesTheCounter, statesItAsZero] }); - - // An observed zero, not an absent field: the two mean different things, and only one - // of the two candidates actually measured this counter. - expect(built.totals.cacheCreationTokens).toBe(0); - - const reversed = report({ records: [statesItAsZero, missesTheCounter] }); - expect(JSON.stringify(reversed)).toBe(JSON.stringify(built)); - }); -}); - -describe("buildCostReport — a local-read session total, the first kind: 'session' report figure (#697)", () => { - const COPILOT_CAPABILITY = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - export: { tokenCounters: false, amount: false, toolStatedStep: false, agentName: false }, - journalAttributable: true, - taskAttributable: false, - } as const; - - function reportWithCopilot(overrides: Partial = {}) { - return buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [ - { tool: "claude", coverage: "covered", capability: NO_CAPABILITY }, - { tool: "copilot", coverage: "covered", capability: COPILOT_CAPABILITY }, - ], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }); - } - - const copilotSession = (overrides: Partial = {}) => - sessionMeasure({ - tool: "copilot", - provenance: "local-read", - input_tokens: 10, - output_tokens: 42, - cache_read_tokens: 0, - cache_creation_tokens: 21070, - ...overrides, - }); - - it("carries a session total on the tool's own row, never on the period total", () => { - const built = reportWithCopilot({ records: [copilotSession()] }); - const copilotRow = built.byTools.find((row) => row.tool === "copilot"); - - expect(copilotRow?.sessionTotals).toEqual({ - requests: 0, - inputTokens: 10, - outputTokens: 42, - cacheReadTokens: 0, - cacheCreationTokens: 21070, - }); - expect(built.totals).toEqual({ requests: 0 }); - }); - - it("never enters by_step or by_day — it reconciles with neither", () => { - const built = reportWithCopilot({ - records: [copilotSession({ event_timestamp: "2026-08-19T10:00:00Z" })], - }); - - expect(built.bySteps).toHaveLength(0); - for (const day of built.byDays) expect(day.totals).toEqual({ requests: 0 }); - }); - - it("stays off every row for a tool with no session-kind local-read record", () => { - const built = reportWithCopilot({ records: [] }); - - for (const row of built.byTools) expect(row.sessionTotals).toBeUndefined(); - }); - - it("never folds an export-route session delta into the by-tool session total", () => { - // Only a local-read "session" record is a one-shot, already-complete total; an - // export-route one is a periodic flush's own delta and is never safe to show this way. - const built = reportWithCopilot({ - records: [copilotSession({ provenance: "export", tool: "claude" })], - }); - const claudeRow = built.byTools.find((row) => row.tool === "claude"); - - expect(claudeRow?.sessionTotals).toBeUndefined(); - }); -}); - -describe("buildCostReport — an absent quantity stays absent", () => { - it("reports no amount for a tool whose records carry none, never a zero", () => { - const built = report({ - records: [request({ tool: "codex", input_tokens: 8898, output_tokens: 827 })], - declaredTools: [{ tool: "codex", coverage: "covered", capability: NO_CAPABILITY }], - }); - - expect(built.totals.costMicroUsd).toBeUndefined(); - expect(built.byTools[0]?.totals.costMicroUsd).toBeUndefined(); - expect(built.byTools[0]?.totals.inputTokens).toBe(8898); - }); - - it("keeps a counter observed as zero distinct from one never observed", () => { - const built = report({ records: [request({ input_tokens: 0 })] }); - - expect(built.totals.inputTokens).toBe(0); - expect(built.totals.outputTokens).toBeUndefined(); - }); - - it("gives a covered tool that did nothing a row of its own, not silence", () => { - const built = report({ - records: [request({ tool: "claude", cost_usd: 1 })], - declaredTools: [ - { tool: "claude", coverage: "covered", capability: NO_CAPABILITY }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - { - tool: "cursor", - coverage: "not-covered", - reason: "It writes no token count.", - capability: NO_CAPABILITY, - }, - ], - }); - - expect(built.byTools.map((row) => [row.tool, row.coverage, row.totals.requests])).toEqual([ - ["claude", "covered", 1], - ["codex", "covered", 0], - ["cursor", "not-covered", 0], - ]); - expect(built.byTools[2]?.reason).toBe("It writes no token count."); - }); -}); - -describe("buildCostReport — every breakdown reconciles", () => { - const RECORDS: readonly TelemetrySinkRecord[] = [ - request({ - turn_id: "a", - cost_usd: 0.1, - input_tokens: 10, - output_tokens: 1, - model: "opus", - step: "aidd-dev:02-implement", - step_attribution: "tool-stated", - }), - request({ - turn_id: "b", - cost_usd: 0.02, - input_tokens: 20, - output_tokens: 2, - model: "opus", - step: "aidd-dev:02-implement", - step_attribution: "journal-interval", - }), - request({ - turn_id: "c", - cost_usd: 0.003, - input_tokens: 30, - output_tokens: 3, - model: "haiku", - step: "aidd-dev:05-review", - step_attribution: "tool-stated", - }), - request({ turn_id: "d", cost_usd: 0.0004, input_tokens: 40, output_tokens: 4, model: "haiku" }), - ]; - - it("sums each breakdown exactly back to the total it belongs to", () => { - const built = report({ records: RECORDS }); - const expected = { - requests: 4, - costMicroUsd: toMicroUsd(0.1) + toMicroUsd(0.02) + toMicroUsd(0.003) + toMicroUsd(0.0004), - inputTokens: 100, - outputTokens: 10, - }; - - expect(built.totals).toMatchObject(expected); - for (const rows of [built.bySteps, built.byModels, built.attributionMix]) { - expect(sumOf(rows)).toEqual(expected); - } - }); - - // Almost all of a real session's tokens are a subagent's, and every one of those lines - // names its agent while almost none names its skill - so `by_step` reads near-empty. - it("breaks the period down by the agent that ran, main thread included as its own row", () => { - const built = report({ - declaredTools: TOOL_THAT_NAMES_AGENTS, - records: [ - request({ agent_name: "aidd-dev:executor", input_tokens: 100 }), - request({ agent_name: "aidd-dev:executor", input_tokens: 50 }), - request({ agent_name: "Explore", input_tokens: 10 }), - request({ input_tokens: 1 }), - ], - }); - - expect(built.byAgents.map((row) => [row.agent, row.attribution, row.totals.requests])).toEqual([ - ["aidd-dev:executor", "tool-stated", 2], - ["Explore", "tool-stated", 1], - [undefined, "main-thread", 1], - ]); - }); - - // Only Claude Code's reader ever sets `agent_name`, so reading its absence as the main - // thread reported every record of every other tool as a main thread on no evidence. - it("claims no main thread for a tool whose route never names an agent", () => { - const built = report({ - declaredTools: [{ tool: "codex", coverage: "covered", capability: NO_CAPABILITY }], - records: [request({ tool: "codex", input_tokens: 4 })], - }); - - expect(built.byAgents.map((row) => [row.agent, row.attribution])).toEqual([ - [undefined, "not-stated"], - ]); - }); - - // A declared route is not a route that names agents: Codex reads token counters and - // names no agent, so its records must read as a tool with no declared route does. - it("claims no main thread for a route that is declared and still names no agent", () => { - const built = report({ - declaredTools: [{ tool: "codex", coverage: "covered", capability: READS_BUT_NAMES_NO_AGENT }], - records: [request({ tool: "codex", input_tokens: 4 })], - }); - - expect(built.byAgents.map((row) => row.attribution)).toEqual(["not-stated"]); - }); - - // Two records that named no agent, from two tools, are two rows and not one: merging - // them would mix unattributable work with a tool's own measured main thread. - it("keeps a main thread apart from a tool that could never have named one", () => { - const built = report({ - declaredTools: [ - ...TOOL_THAT_NAMES_AGENTS, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - ], - records: [ - request({ input_tokens: 5 }), - request({ tool: "codex", input_tokens: 5, vendor_id: "v-codex" }), - ], - }); - - expect(built.byAgents.map((row) => row.attribution).sort()).toEqual([ - "main-thread", - "not-stated", - ]); - }); - - it("reconciles the agent breakdown when a named, a main-thread and an unstated row all exist", () => { - const built = report({ - declaredTools: [ - ...TOOL_THAT_NAMES_AGENTS, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - ], - records: [ - request({ agent_name: "aidd-dev:checker", input_tokens: 7 }), - request({ input_tokens: 3 }), - request({ tool: "codex", input_tokens: 11, vendor_id: "v-codex" }), - ], - }); - - expect(built.byAgents).toHaveLength(3); - const summed = built.byAgents.reduce((total, row) => total + (row.totals.inputTokens ?? 0), 0); - expect(summed).toBe(built.totals.inputTokens); - }); - - it("reconciles the agent breakdown to the same total as every other axis", () => { - const built = report({ - declaredTools: TOOL_THAT_NAMES_AGENTS, - records: [ - request({ agent_name: "aidd-dev:checker", input_tokens: 7 }), - request({ input_tokens: 3 }), - ], - }); - - const summed = built.byAgents.reduce((total, row) => total + (row.totals.inputTokens ?? 0), 0); - expect(summed).toBe(built.totals.inputTokens); - }); - - // The one axis no host limit can empty - which is not the same as complete: the prompt - // is resolved by walking `parentUuid`, not by a capture that may not have happened. - it("breaks the period down by the prompt that caused the work, largest first", () => { - const built = report({ - records: [ - request({ prompt_id: "p-1", input_tokens: 100 }), - request({ prompt_id: "p-1", input_tokens: 50 }), - request({ prompt_id: "p-2", input_tokens: 10 }), - request({ input_tokens: 1 }), - ], - }); - - expect(built.byPrompts.map((row) => [row.prompt, row.totals.requests])).toEqual([ - ["p-1", 2], - ["p-2", 1], - [undefined, 1], - ]); - }); - - // An opaque id alone is unreadable, so the row carries the earliest moment in its group. - // Earliest, never the first seen: the sink is append-ordered by read, not by turn. - it("dates each prompt row by the earliest moment in that prompt, not the first record read", () => { - const built = report({ - records: [ - request({ prompt_id: "p-1", event_timestamp: "2026-08-18T09:30:00.500Z" }), - request({ prompt_id: "p-1", event_timestamp: "2026-08-18T09:00:00.000Z" }), - ], - }); - - expect(built.byPrompts.map((row) => row.startedAt)).toEqual(["2026-08-18T09:00:00Z"]); - }); - - // A record whose tool cannot say which prompt caused it is its own row, never merged - // into one that named a prompt - the same rule `by_agent` and `by_model` follow. - it("leaves records that named no prompt undated rather than dating them from another prompt", () => { - const built = report({ - records: [ - request({ prompt_id: "p-1", event_timestamp: "2026-08-18T09:00:00.000Z" }), - request({ event_timestamp: "2026-08-18T10:00:00.000Z" }), - ], - }); - const noPrompt = built.byPrompts.filter((row) => row.prompt === undefined); - - expect(noPrompt.map((row) => row.startedAt)).toEqual([undefined]); - }); - - // A remainder, not a prompt: sorting it among the prompts by size would rank a bucket - // drawn from many turns against single turns. - it("keeps the row for records that named no prompt last, even when it is the largest", () => { - const built = report({ - records: [request({ input_tokens: 900 }), request({ prompt_id: "p-1", input_tokens: 10 })], - }); - - expect(built.byPrompts.map((row) => row.prompt)).toEqual(["p-1", undefined]); - }); - - // Every counter, not only the one this session happens to look at: 99% of a real - // session's tokens are cache, and `requests` too, which a session record would break. - it("reconciles the prompt breakdown to the same total as every other axis", () => { - const built = report({ - records: [ - request({ prompt_id: "p-1", input_tokens: 7, output_tokens: 5, cache_read_tokens: 4000 }), - request({ input_tokens: 3, output_tokens: 2, cache_creation_tokens: 900 }), - ], - }); - - const summed = built.byPrompts.reduce( - (total, row) => ({ - requests: total.requests + row.totals.requests, - inputTokens: total.inputTokens + (row.totals.inputTokens ?? 0), - outputTokens: total.outputTokens + (row.totals.outputTokens ?? 0), - cacheReadTokens: total.cacheReadTokens + (row.totals.cacheReadTokens ?? 0), - cacheCreationTokens: total.cacheCreationTokens + (row.totals.cacheCreationTokens ?? 0), - }), - { - requests: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheCreationTokens: 0, - } - ); - - expect(summed).toEqual({ - requests: built.totals.requests, - inputTokens: built.totals.inputTokens, - outputTokens: built.totals.outputTokens, - cacheReadTokens: built.totals.cacheReadTokens, - cacheCreationTokens: built.totals.cacheCreationTokens, - }); - }); - - it("splits the total four ways by how strongly each part was attributed", () => { - const built = report({ records: RECORDS }); - expect(built.attributionMix.map((row) => [row.attribution, row.totals.requests])).toEqual([ - ["tool-stated", 2], - ["prompt-matched", 0], - ["journal-interval", 1], - ["unattributed", 1], - ]); - }); - - it("keeps one skill reached both ways as two rows, never merged into one claim", () => { - const built = report({ records: RECORDS }); - const implement = built.bySteps.filter((row) => row.step === "aidd-dev:02-implement"); - - expect(implement.map((row) => row.attribution).sort()).toEqual([ - "journal-interval", - "tool-stated", - ]); - }); - - it("names what nothing could attribute as unattributed, with no step of its own", () => { - const built = report({ records: RECORDS }); - const rows = built.bySteps.filter((row) => row.attribution === "unattributed"); - - expect(rows).toHaveLength(1); - expect(rows[0]?.step).toBeUndefined(); - // Never a residual, and never a claim that the work ran outside every step. - expect(JSON.stringify(built.bySteps)).not.toContain("residual"); - }); - - it("orders each breakdown largest first, so the biggest thing is read first", () => { - const built = report({ records: RECORDS }); - - expect(built.byModels.map((row) => row.model)).toEqual(["opus", "haiku"]); - expect(built.bySteps[0]?.step).toBe("aidd-dev:02-implement"); - }); - - it("orders by tokens where no amount exists, so an amount-less tool is not sorted as free", () => { - const built = report({ - records: [ - request({ turn_id: "small", model: "small", input_tokens: 1, output_tokens: 1 }), - request({ turn_id: "big", model: "big", input_tokens: 900, output_tokens: 100 }), - ], - }); - - expect(built.byModels.map((row) => row.model)).toEqual(["big", "small"]); - }); - - // Every tool this report has ever seen runs at 90%-plus cache, so a weight blind to the - // two cache counters orders a costless breakdown backwards. - it("weighs a costless row by all four counters, cache included - not input and output alone", () => { - const built = report({ - records: [ - request({ - turn_id: "light-cache", - model: "light-cache", - input_tokens: 500, - output_tokens: 500, - }), - request({ - turn_id: "heavy-cache", - model: "heavy-cache", - input_tokens: 10, - output_tokens: 5, - cache_read_tokens: 900_000, - }), - ], - }); - - expect(built.byModels.map((row) => row.model)).toEqual(["heavy-cache", "light-cache"]); - }); -}); - -describe("buildCostReport — by day and by project", () => { - it("gives every day in the period a row, a gap included, and reconciles to the total", () => { - const built = report({ - records: [ - request({ turn_id: "a", cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" }), - request({ turn_id: "b", cost_usd: 3, event_timestamp: "2026-08-19T10:00:00Z" }), - ], - }); - - expect(built.byDays.map((row) => row.day)).toEqual([ - "2026-08-17", - "2026-08-18", - "2026-08-19", - "2026-08-20", - "2026-08-21", - ]); - const gap = built.byDays.find((row) => row.day === "2026-08-18"); - expect(gap?.totals).toEqual({ requests: 0 }); - - const total = built.byDays.reduce((sum, row) => sum + (row.totals.costMicroUsd ?? 0), 0); - expect(total).toBe(built.totals.costMicroUsd); - }); - - it("gives a record with no project its own row, named as unknown", () => { - const built = report({ - records: [ - request({ turn_id: "a", cost_usd: 2, project_id: "acme/widgets" }), - request({ turn_id: "b", cost_usd: 1 }), - ], - }); - - expect(built.byProjects).toHaveLength(2); - const unknown = built.byProjects.find((row) => row.project === undefined); - expect(unknown?.totals.requests).toBe(1); - expect(unknown?.totals.costMicroUsd).toBe(toMicroUsd(1)); - - const total = built.byProjects.reduce((sum, row) => sum + (row.totals.costMicroUsd ?? 0), 0); - expect(total).toBe(built.totals.costMicroUsd); - }); - - it("never folds a record with no project into a neighbour's row", () => { - const built = report({ - records: [ - request({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets" }), - request({ turn_id: "b", cost_usd: 1 }), - ], - }); - const widgets = built.byProjects.find((row) => row.project === "acme/widgets"); - - expect(widgets?.totals.requests).toBe(1); - }); - - // `project_id: ""` is not a name - it is what a tool writes when it has none to give. - // Its own row would print something nobody can act on, and disagree with `projectKeyOf`. - it("treats an empty-string project_id the same as no project at all", () => { - const built = report({ - records: [ - request({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets" }), - request({ turn_id: "b", cost_usd: 2, project_id: "" }), - request({ turn_id: "c", cost_usd: 3 }), - ], - }); - - expect(built.byProjects).toHaveLength(2); - const unknown = built.byProjects.find((row) => row.project === undefined); - expect(unknown?.totals.requests).toBe(2); - expect(unknown?.totals.costMicroUsd).toBe(toMicroUsd(5)); - }); -}); - -describe("buildCostReport — an unknown keeps its row, never a zero", () => { - // Both the Codex and OpenCode readers permit a request record with no model, so without - // this row `byModels` stopped reconciling to its own total with nothing naming the gap. - it("gives a record with no model its own row in byModels, and it still reconciles", () => { - const built = report({ - records: [ - request({ turn_id: "a", cost_usd: 2, model: "opus" }), - request({ turn_id: "b", cost_usd: 1 }), - ], - }); - - expect(built.byModels).toHaveLength(2); - const unknown = built.byModels.find((row) => row.model === undefined); - expect(unknown?.totals.requests).toBe(1); - expect(unknown?.totals.costMicroUsd).toBe(toMicroUsd(1)); - - const total = built.byModels.reduce((sum, row) => sum + (row.totals.costMicroUsd ?? 0), 0); - expect(total).toBe(built.totals.costMicroUsd); - }); - - // `JSON.stringify(NaN)` is `null`, which round-trips as `null !== undefined` - which is - // why `cost_usd` needs a `typeof` guard rather than an `!== undefined` one. - it("reads a non-numeric cost as unknown, never as a zero", () => { - const damaged = parseTelemetrySinkLine( - JSON.stringify({ ...request({ turn_id: "x" }), cost_usd: Number.NaN }) - ); - expect(damaged.cost_usd).toBeNull(); - - const built = report({ records: [damaged] }); - - expect(built.totals.requests).toBe(1); - expect(built.totals.costMicroUsd).toBeUndefined(); - }); - - // `telemetrySinkRecordDayKey` answers `undefined` for a string merely shaped like a - // moment: the record stays in `totals` but invents no day row. - it("gives a damaged moment no day row, while the total still holds it", () => { - const damaged = parseTelemetrySinkLine( - JSON.stringify({ - ...request({ turn_id: "y", cost_usd: 5 }), - event_timestamp: "not-a-momentZ", - }) - ); - const built = report({ records: [damaged] }); - - expect(built.totals.requests).toBe(1); - expect(built.totals.costMicroUsd).toBe(toMicroUsd(5)); - const total = built.byDays.reduce((sum, row) => sum + (row.totals.costMicroUsd ?? 0), 0); - expect(total).toBe(0); - expect(built.byDays.every((row) => row.totals.requests === 0)).toBe(true); - }); -}); - -describe("buildCostReport — a task is a filter over a period", () => { - const JOURNALS: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-task", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - { - vendorId: "s-other", - tool: "claude-code", - writtenPaths: ["cli/src/index.ts"], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const RECORDS: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-task", cost_usd: 1 }), - request({ vendor_id: "s-other", cost_usd: 2 }), - request({ vendor_id: "s-unjournalled", cost_usd: 4 }), - ]; - - it("counts only the sessions that wrote into the task asked for", () => { - const built = report({ - records: RECORDS, - journals: JOURNALS, - task: "2026_08/2026_08_21_cost-reporter", - }); - - expect(built.totals.costMicroUsd).toBe(toMicroUsd(1)); - expect(built.sessions).toBe(1); - expect(built.task).toBe("2026_08/2026_08_21_cost-reporter"); - }); - - it("counts every session when no task is asked for, journalled or not", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - - expect(built.totals.costMicroUsd).toBe(toMicroUsd(7)); - expect(built.sessions).toBe(3); - expect(built.task).toBeUndefined(); - }); - - it("attaches a session that wrote into no task folder to no task at all", () => { - const built = report({ - records: RECORDS, - journals: JOURNALS, - task: "2026_08/some-other-task", - }); - - expect(built.totals.requests).toBe(0); - }); - - it("counts a session with no journal in the period, unattributed to any task", () => { - const built = report({ records: RECORDS, journals: JOURNALS }); - - expect(built.totals.requests).toBe(3); - }); -}); - -describe("buildCostReport — a task can be declared, not just derived", () => { - const WANTED = "2026_08/wanted"; - const WANTED_PATH = "aidd_docs/tasks/2026_08/wanted/spec.md"; - const declared = (at: string, path = WANTED_PATH) => ({ - path, - startMs: Date.parse(at), - endMs: 0, - }); - const closedAt = (open: string, close: string) => ({ - ...declared(open), - endMs: Date.parse(close), - }); - - it("attributes a tool whose payloads name no path at all - a declared interval, never a written file", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-declared", - tool: "codex", - writtenPaths: [], - taskIntervals: [closedAt("2026-08-17T10:00:00Z", "2026-08-17T11:00:00Z")], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-declared", cost_usd: 1, event_timestamp: "2026-08-17T10:30:00Z" }), - ]; - - const built = report({ records, journals, task: WANTED }); - - expect(built.totals.requests).toBe(1); - const mix = Object.fromEntries( - (built.taskAttributionMix ?? []).map((row) => [row.attribution, row.totals.requests]) - ); - expect(mix).toEqual({ declared: 1, inferred: 0 }); - }); - - it("a session that never declared and never wrote into the folder belongs to none - never the last one seen", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-silent", - tool: "codex", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-silent", cost_usd: 9, event_timestamp: "2026-08-17T10:30:00Z" }), - ]; - - expect(report({ records, journals, task: WANTED }).totals.requests).toBe(0); - }); - - it("a declaration left open by one session does not reach a later, unrelated one", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - // Crashed mid-task: an interval with no closing turn_end, capped at its own start. - vendorId: "s-crashed", - tool: "codex", - writtenPaths: [], - taskIntervals: [declared("2026-08-17T10:00:00Z")], - flowIntervals: [], - }, - { - vendorId: "s-later", - tool: "codex", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-later", cost_usd: 5, event_timestamp: "2026-08-20T09:00:00Z" }), - ]; - - expect(report({ records, journals, task: WANTED }).totals.requests).toBe(0); - }); - - it("an unclosed declaration is capped at the journal's own last recorded moment, never left boundless", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-crashed", - tool: "codex", - writtenPaths: [], - taskIntervals: [declared("2026-08-17T10:00:00Z")], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - // A re-read stores this later, but it never happened before the crash. - request({ vendor_id: "s-crashed", cost_usd: 3, event_timestamp: "2026-08-17T10:30:00Z" }), - ]; - - expect(report({ records, journals, task: WANTED }).totals.requests).toBe(0); - }); - - it("a declared interval closes at its own bound - work after it falls back to inferred", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-mixed", - tool: "claude", - writtenPaths: [WANTED_PATH], - taskIntervals: [closedAt("2026-08-17T10:00:00Z", "2026-08-17T10:15:00Z")], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-mixed", cost_usd: 1, event_timestamp: "2026-08-17T10:05:00Z" }), - request({ vendor_id: "s-mixed", cost_usd: 2, event_timestamp: "2026-08-17T10:20:00Z" }), - ]; - - const built = report({ records, journals, task: WANTED }); - - expect(built.totals.requests).toBe(2); - const mix = Object.fromEntries( - (built.taskAttributionMix ?? []).map((row) => [row.attribution, row.totals.requests]) - ); - expect(mix).toEqual({ declared: 1, inferred: 1 }); - }); -}); - -describe("buildCostReport — by_flow reads the journal's own sequence, nothing declared", () => { - it("gives two orchestrated runs of the same skill in one session two rows, never one merged by name", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-two-sdlc-runs", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary", - }, - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T11:00:00Z"), - endMs: Date.parse("2026-08-17T12:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-two-sdlc-runs", - cost_usd: 3, - event_timestamp: "2026-08-17T10:30:00Z", - }), - request({ - vendor_id: "s-two-sdlc-runs", - cost_usd: 7, - event_timestamp: "2026-08-17T11:30:00Z", - }), - ]; - - const built = report({ records, journals }); - - const named = built.byFlows.filter((row) => row.flow !== undefined); - expect(named).toHaveLength(2); - expect(named.every((row) => row.flow === "aidd-orchestrator:01-sdlc")).toBe(true); - expect(named.map((row) => row.totals.requests).sort()).toEqual([1, 1]); - }); - - it("puts a hand-run skill's cost inside the flow it ran during - the journal cannot tell it apart from one the orchestrator invoked", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-hand-run", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T12:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - // A skill a person ran by hand, mid-flow. - request({ - vendor_id: "s-hand-run", - cost_usd: 2, - step: "aidd-dev:02-implement", - event_timestamp: "2026-08-17T10:30:00Z", - }), - ]; - - const built = report({ records, journals }); - - const flowRow = built.byFlows.find((row) => row.flow === "aidd-orchestrator:01-sdlc"); - expect(flowRow?.totals.requests).toBe(1); - }); - - it("gives work before the first orchestrating step its own row, outside any flow", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-before", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-before", cost_usd: 1, event_timestamp: "2026-08-17T09:00:00Z" }), - request({ vendor_id: "s-before", cost_usd: 4, event_timestamp: "2026-08-17T10:30:00Z" }), - ]; - - const built = report({ records, journals }); - - const outside = built.byFlows.find((row) => row.flow === undefined); - expect(outside?.totals.requests).toBe(1); - expect(outside?.totals.costMicroUsd).toBe(toMicroUsd(1)); - }); - - it("puts the outside-every-flow row last even when it is the largest - the tail convention by_task and by_backlog already keep", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-outside-is-biggest", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - // Outside the flow, and worth ten times what ran inside it. Sorted by size alone this - // row led the table; the named run has to come first all the same. - request({ - vendor_id: "s-outside-is-biggest", - cost_usd: 90, - event_timestamp: "2026-08-17T09:00:00Z", - }), - request({ - vendor_id: "s-outside-is-biggest", - cost_usd: 9, - event_timestamp: "2026-08-17T10:30:00Z", - }), - ]; - - const built = report({ records, journals }); - - expect(built.byFlows.map((row) => row.flow)).toEqual(["aidd-orchestrator:01-sdlc", undefined]); - expect(built.byFlows[1]?.totals.costMicroUsd).toBe(toMicroUsd(90)); - }); - - it("gives a session that never ran an orchestrating skill exactly one row, outside every flow, total intact", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-plain", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-plain", cost_usd: 5, event_timestamp: "2026-08-17T10:00:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byFlows).toHaveLength(1); - expect(built.byFlows[0]?.flow).toBeUndefined(); - expect(built.byFlows[0]?.totals.requests).toBe(1); - }); - - it("holds nothing, and says so rather than swallowing later work, for a flow opened at the journal's very last moment", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-opened-last", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - // Unclosed - capped at its own start, the journal's own last witnessed moment. - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T10:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - // At the very moment the flow opened - the half-open interval [t, t) holds nothing. - request({ vendor_id: "s-opened-last", cost_usd: 9, event_timestamp: "2026-08-17T10:00:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byFlows.some((row) => row.flow === "aidd-orchestrator:01-sdlc")).toBe(false); - const outside = built.byFlows.find((row) => row.flow === undefined); - expect(outside?.totals.requests).toBe(1); - }); - - // A session resumed after its context was compacted fires no `step_start`, so its journal - // opens no flow while the transcript goes on stating the step on every record. - it("names the flow a record's own tool stated, where no interval covers it", () => { - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-no-journal", - cost_usd: 4, - event_timestamp: "2026-08-17T10:30:00Z", - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - }), - ]; - - const built = report({ records, journals: [] }); - - const named = built.byFlows.find((row) => row.flow === "aidd-orchestrator:01-sdlc"); - expect(named?.attribution).toBe("tool-stated"); - expect(named?.totals.requests).toBe(1); - // A name is not a run: the row is a bucket drawn from every run of that skill the tool - // named, so it asserts no single moment the way an interval-derived row does. - expect(named?.startedAt).toBeUndefined(); - }); - - it("keeps a record the journal witnessed on the interval's row, never the tool-stated one", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-both", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-both", - cost_usd: 5, - event_timestamp: "2026-08-17T10:30:00Z", - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - }), - ]; - - const built = report({ records, journals }); - - const rows = built.byFlows.filter((row) => row.flow === "aidd-orchestrator:01-sdlc"); - expect(rows).toHaveLength(1); - expect(rows[0]?.attribution).toBe("journal-interval"); - expect(rows[0]?.startedAt).toBe("2026-08-17T10:00:00Z"); - }); - - it("opens no flow for a tool-stated step that names no orchestrating skill", () => { - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-plain-skill", - cost_usd: 6, - event_timestamp: "2026-08-17T10:30:00Z", - step_attribution: "tool-stated", - step: "aidd-dev:01-plan", - }), - ]; - - const built = report({ records, journals: [] }); - - expect(built.byFlows.map((row) => row.flow)).toEqual([undefined]); - expect(built.byFlows[0]?.attribution).toBe("unattributed"); - }); - - // An interval is the only thing that can say *which run*: a step inferred from a moment - // says neither run nor that a flow was ever orchestrated, so it opens no flow row. - it("opens no flow for an orchestrating step the reader merely inferred", () => { - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-inferred", - cost_usd: 7, - event_timestamp: "2026-08-17T10:30:00Z", - step_attribution: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }), - ]; - - const built = report({ records, journals: [] }); - - expect(built.byFlows.map((row) => row.flow)).toEqual([undefined]); - }); - - it("reconciles by_flow to the same total when a tool-stated flow row is present", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-interval", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-interval", cost_usd: 1, event_timestamp: "2026-08-17T10:30:00Z" }), - request({ - vendor_id: "s-stated", - cost_usd: 2, - event_timestamp: "2026-08-17T12:00:00Z", - step_attribution: "tool-stated", - step: "aidd-orchestrator:02-backlog", - }), - request({ vendor_id: "s-neither", cost_usd: 3, event_timestamp: "2026-08-17T13:00:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byFlows).toHaveLength(3); - expect(sumOf(built.byFlows)).toEqual({ - requests: built.totals.requests, - costMicroUsd: built.totals.costMicroUsd, - inputTokens: built.totals.inputTokens ?? 0, - outputTokens: built.totals.outputTokens ?? 0, - }); - }); - - it("reconciles by_flow to the same total as every other breakdown", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-mixed-flows", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary", - }, - { - skill: "aidd-orchestrator:02-backlog", - startMs: Date.parse("2026-08-17T11:00:00Z"), - endMs: Date.parse("2026-08-17T12:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ vendor_id: "s-mixed-flows", cost_usd: 1, event_timestamp: "2026-08-17T09:00:00Z" }), - request({ vendor_id: "s-mixed-flows", cost_usd: 2, event_timestamp: "2026-08-17T10:30:00Z" }), - request({ vendor_id: "s-mixed-flows", cost_usd: 3, event_timestamp: "2026-08-17T11:30:00Z" }), - ]; - - const built = report({ records, journals }); - - expect(built.byFlows).toHaveLength(3); - expect(sumOf(built.byFlows)).toEqual({ - requests: built.totals.requests, - costMicroUsd: built.totals.costMicroUsd, - inputTokens: built.totals.inputTokens ?? 0, - outputTokens: built.totals.outputTokens ?? 0, - }); - }); - - it("opens no flow for a skill outside the declared set, however plausible its name", () => { - const journals: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-unrelated", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [], - }, - ]; - const records: readonly TelemetrySinkRecord[] = [ - request({ - vendor_id: "s-unrelated", - cost_usd: 1, - step: "aidd-orchestrator:03-does-not-exist", - event_timestamp: "2026-08-17T10:00:00Z", - }), - ]; - - const built = report({ records, journals }); - - expect(built.byFlows).toHaveLength(1); - expect(built.byFlows[0]?.flow).toBeUndefined(); - }); -}); - -describe("buildCostReport — what it says about itself", () => { - it("carries the undated and unreadable counts through to the caller", () => { - const built = report({ undatedRecords: 4, unreadableLines: 2 }); - - expect(built.undatedRecords).toBe(4); - expect(built.unreadableLines).toBe(2); - }); - - it("answers an empty period with an empty report, never an error", () => { - const built = report(); - - expect(built.sessions).toBe(0); - expect(built.totals).toEqual({ requests: 0 }); - expect(built.bySteps).toEqual([]); - expect(built.byModels).toEqual([]); - // Four rows even here: the total is known to be nothing, and none of it came from - // any source. That is a measurement, not an absence. - expect(built.attributionMix.map((row) => [row.attribution, row.totals.requests])).toEqual([ - ["tool-stated", 0], - ["prompt-matched", 0], - ["journal-interval", 0], - ["unattributed", 0], - ]); - }); - - it("names no tool and no skill, by string literal", () => { - const source = readFileSync( - fileURLToPath( - new URL("../../../../src/contexts/telemetry/domain/cost-report.ts", import.meta.url) - ), - "utf8" - ); - - for (const toolId of AI_TOOL_IDS) { - expect(source).not.toContain(`"${toolId}"`); - expect(source).not.toContain(`'${toolId}'`); - } - expect(source).not.toContain("aidd-dev:"); - }); -}); - -describe("buildCostReport — the same records, however they arrive", () => { - // A re-read appends, so the same session's lines sit in different orders on two machines. - // Repetition alone would never catch a group that carries insertion order. - const RECORDS: readonly TelemetrySinkRecord[] = [ - request({ - turn_id: "a", - cost_usd: 1, - model: "opus", - step: "implement", - step_attribution: "tool-stated", - }), - request({ - turn_id: "b", - cost_usd: 1, - model: "haiku", - step: "review", - step_attribution: "journal-interval", - }), - request({ turn_id: "c", cost_usd: 2, model: "sonnet", tool: "codex" }), - sessionMeasure({ active_time_s: 12 }), - ]; - - const DECLARED = [ - { tool: "claude", coverage: "covered", capability: NO_CAPABILITY }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - ] as const; - - it("produces a byte-identical report from the records reversed", () => { - const forwards = report({ records: RECORDS, declaredTools: DECLARED }); - const backwards = report({ records: [...RECORDS].reverse(), declaredTools: DECLARED }); - - expect(JSON.stringify(backwards)).toBe(JSON.stringify(forwards)); - }); - - it("produces a byte-identical report twice from the same records", () => { - expect(JSON.stringify(report({ records: RECORDS, declaredTools: DECLARED }))).toBe( - JSON.stringify(report({ records: RECORDS, declaredTools: DECLARED })) - ); - }); - - it("keeps the same order when two rows carry equal weight", () => { - // Two models, identical figures: only the tie-break on the row's own key can decide, - // and it has to decide the same way whichever order they arrived in. - const tied: readonly TelemetrySinkRecord[] = [ - request({ turn_id: "x", cost_usd: 1, model: "zulu" }), - request({ turn_id: "y", cost_usd: 1, model: "alpha" }), - ]; - - const forwards = report({ records: tied }).byModels.map((row) => row.model); - const backwards = report({ records: [...tied].reverse() }).byModels.map((row) => row.model); - - expect(forwards).toEqual(["alpha", "zulu"]); - expect(backwards).toEqual(forwards); - }); -}); - -describe("buildCostReport — any dimension filters as well as it groups", () => { - const AT = "2026-08-18T10:00:00Z"; - const WIDGETS: readonly TelemetrySinkRecord[] = [ - request({ - turn_id: "a", - cost_usd: 1, - model: "opus", - step: "impl", - step_attribution: "tool-stated", - tool: "claude", - project_id: "acme/widgets", - event_timestamp: AT, - }), - request({ - turn_id: "b", - cost_usd: 2, - model: "sonnet", - step: "review", - step_attribution: "tool-stated", - tool: "codex", - project_id: "acme/widgets", - event_timestamp: AT, - }), - ]; - const GADGETS: readonly TelemetrySinkRecord[] = [ - request({ - turn_id: "c", - cost_usd: 4, - model: "opus", - step: "impl", - step_attribution: "tool-stated", - tool: "claude", - project_id: "acme/gadgets", - event_timestamp: AT, - }), - ]; - const declaredTools = [ - { tool: "claude" as const, coverage: "covered" as const, capability: NO_CAPABILITY }, - { tool: "codex" as const, coverage: "covered" as const, capability: NO_CAPABILITY }, - ]; - const knownValues = { - projects: new Set(["acme/widgets", "acme/gadgets"]), - steps: new Set(["impl", "review"]), - // "haiku" never appears on a WIDGETS/GADGETS record - known to the sweep, idle here. - models: new Set(["opus", "sonnet", "haiku"]), - }; - - function narrowed(overrides: Partial = {}) { - return report({ records: [...WIDGETS, ...GADGETS], declaredTools, knownValues, ...overrides }); - } - - it("narrows two filters to their intersection, never their union", () => { - const built = narrowed({ filters: { project: "acme/widgets", model: "opus" } }); - - expect(built.totals.requests).toBe(1); - expect(built.totals.costMicroUsd).toBe(toMicroUsd(1)); - }); - - it("says which selection it answered", () => { - const built = narrowed({ filters: { project: "acme/widgets", step: "impl" } }); - - expect(built.filters).toEqual({ project: "acme/widgets", step: "impl" }); - }); - - it("filtering and grouping on the same single-keyed dimension answers with one row", () => { - const byProject = narrowed({ filters: { project: "acme/widgets" } }); - expect(byProject.byProjects).toHaveLength(1); - expect(byProject.byProjects[0]?.project).toBe("acme/widgets"); - - const byModel = narrowed({ filters: { model: "opus" } }); - expect(byModel.byModels).toHaveLength(1); - - // by_tool is a breakdown of every *declared* tool - a --tool filter has to narrow that - // list too, or an excluded tool would print a row a measured idle is not told from. - const byTool = narrowed({ filters: { tool: "codex" } }); - expect(byTool.byTools).toHaveLength(1); - expect(byTool.byTools[0]?.tool).toBe("codex"); - expect(byTool.byTools[0]?.totals.requests).toBe(1); - }); - - it("keeps a session-only figure under a step filter when a journal interval stamped one", () => { - // Unlike model, a step can land on a session record: `resolveStepAttribution` runs over - // every candidate regardless of kind. - const sessionRecord: TelemetrySinkRecord = sessionMeasure({ - vendor_id: "s-3", - tool: "claude", - active_time_s: 30, - project_id: "acme/widgets", - step: "impl", - step_attribution: "journal-interval", - }); - - const withStep = narrowed({ filters: { step: "impl" }, records: [...WIDGETS, sessionRecord] }); - const withOtherStep = narrowed({ - filters: { step: "review" }, - records: [...WIDGETS, sessionRecord], - }); - - expect(withStep.activeTimeSeconds).toBe(30); - expect(withOtherStep.activeTimeSeconds).toBeUndefined(); - }); - - it("says a tool was never seen without claiming a record check it never ran", () => { - const built = narrowed({ filters: { tool: "opencode" } }); - - expect(built.emptySelection).toEqual({ filter: "tool", value: "opencode", known: false }); - }); - - it("reconciles every breakdown to this selection's own total, exactly", () => { - const built = narrowed({ filters: { project: "acme/widgets" } }); - const total = (rows: readonly { readonly totals: CostTotals }[]) => - rows.reduce((sum, row) => sum + row.totals.requests, 0); - - for (const rows of [built.bySteps, built.byModels, built.byProjects, built.byDays]) { - expect(total(rows)).toBe(built.totals.requests); - } - }); - - it("names the filter that emptied a selection a project nobody ever worked in", () => { - const built = narrowed({ filters: { project: "nobody-worked-here" } }); - - expect(built.emptySelection).toEqual({ - filter: "project", - value: "nobody-worked-here", - known: false, - }); - }); - - it("tells that empty apart from a known value with no work in this period", () => { - const built = narrowed({ filters: { model: "haiku" } }); - - expect(built.emptySelection).toEqual({ filter: "model", value: "haiku", known: true }); - }); - - it("names the combination, not either filter alone, when both are real but their overlap is empty", () => { - const built = narrowed({ filters: { project: "acme/gadgets", model: "sonnet" } }); - - expect(built.emptySelection).toEqual({ - filter: "model", - value: "sonnet", - known: true, - combination: true, - }); - }); - - it("never reports a filter as the culprit when the period itself has nothing", () => { - const built = report({ - fromDay: "2020-01-01", - toDay: "2020-01-01", - filters: { project: "acme/widgets" }, - }); - - expect(built.emptySelection).toBeUndefined(); - }); - - it("drops a session-only figure a model filter cannot speak to, never as a false zero", () => { - const sessionRecord: TelemetrySinkRecord = sessionMeasure({ - vendor_id: "s-2", - tool: "claude", - active_time_s: 30, - project_id: "acme/widgets", - }); - - const withModel = narrowed({ - filters: { model: "opus" }, - records: [...WIDGETS, sessionRecord], - }); - const withProject = narrowed({ - filters: { project: "acme/widgets" }, - records: [...WIDGETS, sessionRecord], - }); - - expect(withModel.activeTimeSeconds).toBeUndefined(); - expect(withProject.activeTimeSeconds).toBe(30); - }); -}); - -describe("buildCostReport — a line on disk holds whatever it holds, not what a type declares", () => { - /** A record carrying a field of the wrong type, built the only way one ever reaches this - * module: through the real parse, which checks `sink_schema_version` and casts the rest. */ - function recordFromLine(overrides: Record): TelemetrySinkRecord { - return parseTelemetrySinkLine(JSON.stringify({ ...sessionMeasure(), ...overrides })); - } - - it("ignores an active time that is not a number, rather than adding it", () => { - const built = report({ - records: [recordFromLine({ active_time_s: "12" }), recordFromLine({ active_time_s: 30 })], - }); - - expect(built.activeTimeSeconds).toBe(30); - }); - - it("leaves a null active time unobserved, rather than counting it as a zero", () => { - const built = report({ records: [recordFromLine({ active_time_s: null })] }); - - expect(built.activeTimeSeconds).toBeUndefined(); - }); - - it("still sums the active times that are numbers", () => { - const built = report({ - records: [recordFromLine({ active_time_s: 12 }), recordFromLine({ active_time_s: 30 })], - }); - - expect(built.activeTimeSeconds).toBe(42); - }); -}); - -describe("buildCostReport — a field with nothing to say is absent, never an undefined key", () => { - it("carries exactly the keys an unfiltered, request-only period fills", () => { - const built = report({ records: [request({ cost_usd: 1 })] }); - - expect(Object.keys(built)).toStrictEqual([ - "fromDay", - "toDay", - "sessions", - "totals", - "bySteps", - "byModels", - "byAgents", - "byPrompts", - "byTools", - "byProjects", - "byTasks", - "byBacklog", - "byFlows", - "byDays", - "byPeople", - "attributionMix", - "undatedRecords", - "unreadableLines", - "measurementEnabled", - ]); - }); -}); - -describe("TotalsAccumulator", () => { - it("builds exactly the requests count for a record carrying neither cost nor counters", () => { - const accumulator = new TotalsAccumulator(); - accumulator.add(request()); - - expect(accumulator.build()).toStrictEqual({ requests: 1 }); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts deleted file mode 100644 index 1f59952b9..000000000 --- a/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - bareOrchestratingSkillNames, - buildFlowIntervals, - ORCHESTRATING_SKILLS, -} from "../../../../src/contexts/telemetry/domain/flow-attribution.js"; -import { momentFallsWithin } from "../../../../src/contexts/telemetry/domain/journal-intervals.js"; -import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; - -function journalOf( - boundaries: RunJournal["boundaries"], - taskDeclarations: RunJournal["taskDeclarations"] = [], - filesWritten: RunJournal["filesWritten"] = [] -): RunJournal { - return { boundaries, filesWritten, taskDeclarations }; -} - -const SDLC_OPENS = { - type: "step_start", - at: "2026-08-17T10:00:00Z", - skill: "aidd-orchestrator:01-sdlc", -} as const; -const BACKLOG_OPENS = { - type: "step_start", - at: "2026-08-17T11:00:00Z", - skill: "aidd-orchestrator:02-backlog", -} as const; -const HAND_RUN_STEP = { - type: "step_start", - at: "2026-08-17T10:10:00Z", - skill: "aidd-dev:02-implement", -} as const; -const TURN_END = { type: "turn_end", at: "2026-08-17T12:00:00Z" } as const; -const EARLIER_TURN_END = { type: "turn_end", at: "2026-08-17T11:00:00Z" } as const; -/** The orchestrating skill saying its own work is over. */ -const SDLC_ENDS = { - type: "step_end", - at: "2026-08-17T11:00:00Z", - skill: "aidd-orchestrator:01-sdlc", -} as const; -/** A step *inside* the orchestration saying it is done - which the orchestration is not. */ -const PLAN_ENDS = { - type: "step_end", - at: "2026-08-17T11:00:00Z", - skill: "aidd-dev:01-plan", -} as const; -/** Something the journal witnessed after all of the above, so "closed there" and "not closed - * there" are two different numbers rather than the same one twice. */ -const WRITTEN_LATE = { - type: "file_written", - at: "2026-08-17T11:30:00Z", - path: "aidd_docs/tasks/x/spec.md", -} as const; - -describe("ORCHESTRATING_SKILLS — declared once, both capture spellings", () => { - it("names every orchestrator skill, in the argument spelling and the bare directory spelling", () => { - expect([...ORCHESTRATING_SKILLS].sort()).toEqual( - [ - "00-async-dev", - "01-sdlc", - "02-backlog", - "aidd-orchestrator:00-async-dev", - "aidd-orchestrator:01-sdlc", - "aidd-orchestrator:02-backlog", - ].sort() - ); - }); - - it("matches no plugin name in passing - nothing here reads a prefix or a substring", () => { - expect(ORCHESTRATING_SKILLS.has("aidd-orchestrator")).toBe(false); - expect(ORCHESTRATING_SKILLS.has("aidd-orchestrator:03-does-not-exist")).toBe(false); - }); - - it("hands out the unqualified spellings alone, sorted - the ones a project can collide with", () => { - expect(bareOrchestratingSkillNames()).toEqual(["00-async-dev", "01-sdlc", "02-backlog"]); - }); - - it("sorts the bare names whatever order the set was declared in", () => { - expect(bareOrchestratingSkillNames(new Set(["zz:b-flow", "b-flow", "a-flow"]))).toStrictEqual([ - "a-flow", - "b-flow", - ]); - }); - - it("hands out a project's fourth orchestrator too, without anything else being told about it", () => { - const extended = new Set([...ORCHESTRATING_SKILLS, "acme:03-release", "03-release"]); - - expect(bareOrchestratingSkillNames(extended)).toEqual([ - "00-async-dev", - "01-sdlc", - "02-backlog", - "03-release", - ]); - }); -}); - -describe("buildFlowIntervals — pure: journal lines -> bounded flow intervals", () => { - it("opens a flow at an orchestrating step_start and closes it at the next one", () => { - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS, BACKLOG_OPENS, TURN_END])); - - expect(intervals).toEqual([ - { - skill: SDLC_OPENS.skill, - startMs: Date.parse(SDLC_OPENS.at), - endMs: Date.parse(BACKLOG_OPENS.at), - closedBy: "boundary", - }, - { - skill: BACKLOG_OPENS.skill, - startMs: Date.parse(BACKLOG_OPENS.at), - endMs: Date.parse(TURN_END.at), - // A pause never closes a flow, so this is the cap at the journal's last moment, - // which the pause happens to be. - closedBy: "journal-end", - }, - ]); - }); - - it("closes a flow where its own skill said it was done", () => { - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS, SDLC_ENDS, TURN_END])); - - expect(intervals).toEqual([ - { - skill: SDLC_OPENS.skill, - startMs: Date.parse(SDLC_OPENS.at), - endMs: Date.parse(SDLC_ENDS.at), - closedBy: "boundary", - }, - ]); - }); - - it("closes a flow opened by its bare name with the end the orchestrator declares in full", () => { - // Cursor and Codex write `01-sdlc` into step_start, the plugin never reaching the - // journal, while the end the skill echoes always carries it. - const bareOpener = { - type: "step_start", - at: "2026-08-17T10:00:00Z", - skill: "01-sdlc", - } as const; - const intervals = buildFlowIntervals(journalOf([bareOpener, SDLC_ENDS], [], [WRITTEN_LATE])); - - expect(intervals[0]?.endMs).toBe(Date.parse(SDLC_ENDS.at)); - }); - - it("still refuses an end whose plugin disagrees with the one that opened the flow", () => { - const otherPlugin = { - type: "step_end", - at: "2026-08-17T11:00:00Z", - skill: "acme:01-sdlc", - } as const; - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS, otherPlugin], [], [WRITTEN_LATE])); - - expect(intervals[0]?.endMs).toBe(Date.parse(WRITTEN_LATE.at)); - }); - - it("never closes a flow on a step_end naming some other skill", () => { - // A step run inside the orchestration ends; the orchestration does not. Something is - // witnessed after that end, so the two readings are different numbers. - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS, PLAN_ENDS], [], [WRITTEN_LATE])); - - expect(intervals[0]?.endMs).toBe(Date.parse(WRITTEN_LATE.at)); - expect(intervals[0]?.endMs).not.toBe(Date.parse(PLAN_ENDS.at)); - }); - - it("does not close a flow at a turn_end - a pause is not the end of an orchestration", () => { - // With `turn_end` last, closing there and capping at the journal's end are the same - // number: only something witnessed after the pause tells the two rules apart. - const intervals = buildFlowIntervals( - journalOf([SDLC_OPENS, EARLIER_TURN_END], [], [WRITTEN_LATE]) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse(WRITTEN_LATE.at)); - expect(intervals[0]?.endMs).not.toBe(Date.parse(EARLIER_TURN_END.at)); - }); - - it("keeps work done after the pause inside the flow that was still running", () => { - const intervals = buildFlowIntervals( - journalOf([SDLC_OPENS, EARLIER_TURN_END], [], [WRITTEN_LATE]) - ); - - expect(momentFallsWithin(intervals, "2026-08-17T10:30:00Z")).toBe(true); - expect(momentFallsWithin(intervals, "2026-08-17T11:15:00Z")).toBe(true); - }); - - it("stops at the end its own skill declared, even with work witnessed after it", () => { - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS, SDLC_ENDS], [], [WRITTEN_LATE])); - - expect(momentFallsWithin(intervals, "2026-08-17T10:30:00Z")).toBe(true); - expect(momentFallsWithin(intervals, "2026-08-17T11:15:00Z")).toBe(false); - }); - - it("never lets a hand-run, non-orchestrating step_start close an open flow", () => { - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS, HAND_RUN_STEP, TURN_END])); - - expect(intervals).toEqual([ - { - skill: SDLC_OPENS.skill, - startMs: Date.parse(SDLC_OPENS.at), - endMs: Date.parse(TURN_END.at), - closedBy: "journal-end", - }, - ]); - }); - - it("opens two distinct intervals for the same skill run twice in one session, never merged into one", () => { - const secondSdlcRun = { ...SDLC_OPENS, at: "2026-08-17T13:00:00Z" }; - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS, TURN_END, secondSdlcRun])); - - expect(intervals).toHaveLength(2); - expect(intervals[0]).not.toBe(intervals[1]); - expect(intervals.map((interval) => interval.skill)).toEqual([ - SDLC_OPENS.skill, - SDLC_OPENS.skill, - ]); - expect(intervals[0]?.endMs).toBe(Date.parse(secondSdlcRun.at)); - expect(intervals[1]?.endMs).toBe(Date.parse(secondSdlcRun.at)); // unclosed - capped at its own start - }); - - it("matches the bare directory spelling a Cursor or Codex payload actually writes", () => { - const bareSpelling = { - type: "step_start", - at: "2026-08-17T10:00:00Z", - skill: "01-sdlc", - } as const; - const intervals = buildFlowIntervals(journalOf([bareSpelling, TURN_END])); - - expect(intervals).toEqual([ - { - skill: "01-sdlc", - startMs: Date.parse(bareSpelling.at), - endMs: Date.parse(TURN_END.at), - closedBy: "journal-end", - }, - ]); - }); - - it("caps an unclosed flow at its own moment, never at Infinity", () => { - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS])); - - expect(intervals).toEqual([ - { - skill: SDLC_OPENS.skill, - startMs: Date.parse(SDLC_OPENS.at), - endMs: Date.parse(SDLC_OPENS.at), - closedBy: "journal-end", - }, - ]); - }); - - it("widens an unclosed flow's end to the journal's own last witnessed moment - a file written after it, no turn_end yet", () => { - const writtenAfter = { - type: "file_written", - at: "2026-08-17T11:30:00Z", - path: "aidd_docs/tasks/x/spec.md", - } as const; - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS], [], [writtenAfter])); - - expect(intervals[0]?.endMs).toBe(Date.parse(writtenAfter.at)); - }); - - it("clamps an unclosed flow's end to the report's own period end, never past a clock-skewed future moment", () => { - const farFuture = { - type: "file_written", - at: "9999-12-31T00:00:00Z", - path: "aidd_docs/tasks/x/spec.md", - } as const; - const periodEndMs = Date.parse("2026-08-18T00:00:00Z"); - - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS], [], [farFuture]), periodEndMs); - - expect(intervals[0]?.endMs).toBe(periodEndMs); - }); - - it("declares no flow interval at all for a session that never ran an orchestrating skill", () => { - const intervals = buildFlowIntervals(journalOf([HAND_RUN_STEP, TURN_END])); - - expect(intervals).toEqual([]); - }); - - it("touches no filesystem — the module imports none of Node's fs APIs", async () => { - const source = await import("node:fs").then((fs) => - fs.readFileSync( - new URL("../../../../src/contexts/telemetry/domain/flow-attribution.ts", import.meta.url), - "utf-8" - ) - ); - expect(source).not.toMatch(/from ["']node:fs/u); - }); -}); - -describe("buildFlowIntervals — a journal with no readable moment in it", () => { - it("builds nothing from a journal with no boundary at all", () => { - expect(buildFlowIntervals(journalOf([]))).toEqual([]); - }); - - it("builds nothing when every moment it holds is unparseable - never an interval bounded by NaN", () => { - const unreadable = { - type: "step_start", - at: "the seventeenth", - skill: "aidd-orchestrator:01-sdlc", - } as const; - - expect(buildFlowIntervals(journalOf([unreadable]))).toEqual([]); - }); - - it("ends a flow nothing ever closed at the journal's own last witnessed moment", () => { - const wroteLater = { - type: "file_written", - at: "2026-08-17T10:45:00Z", - path: "aidd_docs/tasks/2026_08/2026_08_17_alpha/plan.md", - source: "stated", - } as const; - - const intervals = buildFlowIntervals(journalOf([SDLC_OPENS], [], [wroteLater])); - - expect(intervals).toEqual([ - { - skill: SDLC_OPENS.skill, - startMs: Date.parse(SDLC_OPENS.at), - endMs: Date.parse(wroteLater.at), - closedBy: "journal-end", - }, - ]); - }); -}); - -describe("buildFlowIntervals — the limit the flow axis prints beside its figures", () => { - it("opens a flow on a bare 01-sdlc, whichever project's own skills/ directory named it", () => { - const projectsOwnSkill = { - type: "step_start", - at: "2026-08-17T10:00:00Z", - skill: "01-sdlc", - } as const; - - const intervals = buildFlowIntervals(journalOf([projectsOwnSkill, TURN_END])); - - expect(intervals).toHaveLength(1); - expect(intervals[0]?.skill).toBe("01-sdlc"); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts deleted file mode 100644 index 50d411968..000000000 --- a/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript-fields.unit.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; - -const SID = "22222222-2222-4222-8222-222222222222"; - -const USAGE = { - input_tokens: 10, - output_tokens: 5, - cache_read_input_tokens: 2, - cache_creation_input_tokens: 1, -}; - -const COUNTERS = { - input_tokens: 10, - output_tokens: 5, - cache_read_tokens: 2, - cache_creation_tokens: 1, -}; - -const BARE_RECORD = { - kind: "request", - vendor_id: SID, - vendor_field: "sessionId", - ...COUNTERS, -}; - -function chain(lines: readonly Record[]): string { - return lines.map((line) => JSON.stringify(line)).join("\n"); -} - -function assistantLine(overrides: Record = {}): Record { - return { - type: "assistant", - sessionId: SID, - message: { id: "msg_1", usage: USAGE }, - ...overrides, - }; -} - -function withUsage(usage: Record): string { - return chain([assistantLine({ message: { id: "msg_1", usage } })]); -} - -function skillCall(parts: readonly unknown[]): Record { - return { - type: "assistant", - uuid: "a1", - parentUuid: "u1", - sessionId: SID, - message: { content: parts }, - }; -} - -function promptedRecords(callParts: readonly unknown[]) { - return mapClaudeCodeTranscriptToSinkRecords( - chain([ - { type: "user", uuid: "u1", promptId: "p-abc" }, - skillCall(callParts), - assistantLine({ uuid: "a2", parentUuid: "a1" }), - ]) - ); -} - -describe("a billed line is one that carries every counter as a number", () => { - it("yields no record when input_tokens is missing", () => { - const { input_tokens: _dropped, ...usage } = USAGE; - - expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); - }); - - it("yields no record when cache_read_input_tokens is missing", () => { - const { cache_read_input_tokens: _dropped, ...usage } = USAGE; - - expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); - }); - - it("yields no record when output_tokens is missing", () => { - const { output_tokens: _dropped, ...usage } = USAGE; - - expect(mapClaudeCodeTranscriptToSinkRecords(withUsage(usage))).toStrictEqual([]); - }); - - it("yields no record when a counter is a string rather than a number", () => { - const content = withUsage({ ...USAGE, input_tokens: "10" }); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); - }); -}); - -describe("a billed line names its session and is an assistant turn", () => { - it("yields no record when sessionId is absent", () => { - const content = chain([assistantLine({ sessionId: undefined })]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); - }); - - it("yields no record when sessionId is not a string", () => { - const content = chain([assistantLine({ sessionId: 123 })]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); - }); - - it("yields no record for a user line, even one carrying counters", () => { - const content = chain([assistantLine({ type: "user" })]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([]); - }); -}); - -describe("the record carries exactly the keys the line states", () => { - it("holds identity and counters alone when the line names no request, model, effort or moment", () => { - const content = chain([assistantLine()]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); - }); - - it("names the agent only when the line is a sidechain", () => { - const content = chain([assistantLine({ attributionAgent: "Explore" })]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); - }); - - it("names the plugin beside the skill the line attributes", () => { - const content = chain([ - assistantLine({ attributionSkill: "aidd-dev:01-plan", attributionPlugin: "aidd-dev" }), - ]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ - { ...BARE_RECORD, step: "aidd-dev:01-plan", step_plugin: "aidd-dev" }, - ]); - }); - - it("names no plugin when the line attributes a plugin but no skill", () => { - const content = chain([assistantLine({ attributionPlugin: "aidd-dev" })]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([BARE_RECORD]); - }); - - it("carries the prompt id with no prompt_skill key when the prompt invoked no skill", () => { - const content = chain([ - { type: "user", uuid: "u1", promptId: "p-abc" }, - assistantLine({ uuid: "a1", parentUuid: "u1" }), - ]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ - { ...BARE_RECORD, prompt_id: "p-abc" }, - ]); - }); -}); - -describe("reading the Skill call that names a prompt's step", () => { - it("passes over a Skill call that carries no input and reads the next one", () => { - const records = promptedRecords([ - { type: "tool_use", name: "Skill" }, - { type: "tool_use", name: "Skill", input: { skill: "aidd-dev:02-implement" } }, - ]); - - expect(records[0]?.prompt_skill).toBe("aidd-dev:02-implement"); - }); - - it("passes over a null content part rather than throwing", () => { - const records = promptedRecords([ - null, - { type: "tool_use", name: "Skill", input: { skill: "aidd-dev:02-implement" } }, - ]); - - expect(records[0]?.prompt_skill).toBe("aidd-dev:02-implement"); - }); - - it("ignores a part named Skill whose type is not tool_use", () => { - const records = promptedRecords([ - { type: "text", name: "Skill", input: { skill: "aidd-dev:01-plan" } }, - ]); - - expect(records).toStrictEqual([{ ...BARE_RECORD, prompt_id: "p-abc" }]); - }); -}); - -describe("two lines restating one call", () => { - it("collapse on message.id even when neither carries a requestId", () => { - const content = chain([ - assistantLine({ message: { id: "msg_1", usage: { ...USAGE, output_tokens: 1 } } }), - assistantLine({ message: { id: "msg_1", usage: { ...USAGE, output_tokens: 9 } } }), - ]); - - expect(mapClaudeCodeTranscriptToSinkRecords(content)).toStrictEqual([ - { ...BARE_RECORD, output_tokens: 9 }, - ]); - }); - - it("collapse on their text, whitespace aside, when neither carries any id", () => { - const line = JSON.stringify(assistantLine({ message: { usage: USAGE } })); - - expect(mapClaudeCodeTranscriptToSinkRecords(`${line}\n ${line} \n`)).toStrictEqual([ - BARE_RECORD, - ]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript.unit.test.ts deleted file mode 100644 index 27b266fd2..000000000 --- a/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript.unit.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - createClaudeCodeTranscriptAccumulator, - mapClaudeCodeTranscriptToSinkRecords, -} from "../../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; - -const SID = "22222222-2222-4222-8222-222222222222"; - -// Both fixtures are real, redacted session excerpts; the measurement behind them is the header -// comment in `claude-code-transcript.ts`. -function loadFixture(relativePath: string): string { - const url = new URL(`../../../../fixtures/local-cost/${relativePath}`, import.meta.url); - return readFileSync(fileURLToPath(url), "utf8"); -} - -const MAIN_PATH = `.claude/projects/fake-project/${SID}.jsonl`; -const SUBAGENT_PATH = `.claude/projects/fake-project/${SID}/subagents/agent-aa81cdef3bb58820c.jsonl`; - -/** No transcript line carries both `requestId` and `promptId`: only `type: "user"` lines name a - * prompt, and a billed line reaches one by following `parentUuid`. */ -function chain(lines: readonly Record[]): string { - return lines.map((line) => JSON.stringify(line)).join("\n"); -} - -function assistantLine(overrides: Record): Record { - return { - type: "assistant", - sessionId: SID, - requestId: "req_1", - // All four counters or none: `readCounters` refuses a partial `usage` rather than - // reading a missing one as zero, so a fixture with two of them yields no record at all. - message: { - id: "msg_1", - model: "claude-opus-5", - usage: { - input_tokens: 10, - output_tokens: 5, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - ...overrides, - }; -} - -describe("mapClaudeCodeTranscriptToSinkRecords — the prompt a billed call belongs to", () => { - it("follows parentUuid up to the user line that carries the prompt id", () => { - const content = chain([ - { type: "user", uuid: "u1", promptId: "p-abc" }, - { type: "assistant", uuid: "a1", parentUuid: "u1", sessionId: SID }, - assistantLine({ uuid: "a2", parentUuid: "a1" }), - ]); - - const [record] = mapClaudeCodeTranscriptToSinkRecords(content); - - expect(record?.prompt_id).toBe("p-abc"); - }); - - it("carries no prompt id when the chain reaches no line that names one", () => { - const content = chain([assistantLine({ uuid: "a1" })]); - - const [record] = mapClaudeCodeTranscriptToSinkRecords(content); - - expect(record?.prompt_id).toBeUndefined(); - }); - - // `attributionSkill` is exact where it appears and sparse where it does not, so its absence - // is never the tool stating that no skill ran. - it("names the skill a Skill call invoked inside the record's own prompt", () => { - const content = chain([ - { type: "user", uuid: "u1", promptId: "p-abc" }, - { - type: "assistant", - uuid: "a1", - parentUuid: "u1", - sessionId: SID, - message: { - content: [{ type: "tool_use", name: "Skill", input: { skill: "aidd-dev:01-plan" } }], - }, - }, - assistantLine({ uuid: "a2", parentUuid: "a1" }), - ]); - - const [record] = mapClaudeCodeTranscriptToSinkRecords(content); - - expect(record?.prompt_skill).toBe("aidd-dev:01-plan"); - }); - - it("names no skill for a prompt that invoked none", () => { - const content = chain([ - { type: "user", uuid: "u1", promptId: "p-one" }, - { - type: "assistant", - uuid: "a1", - parentUuid: "u1", - sessionId: SID, - message: { - content: [{ type: "tool_use", name: "Skill", input: { skill: "aidd-dev:01-plan" } }], - }, - }, - { type: "user", uuid: "u2", parentUuid: "a1", promptId: "p-two" }, - assistantLine({ uuid: "a2", parentUuid: "u2" }), - ]); - - const records = mapClaudeCodeTranscriptToSinkRecords(content); - - expect(records.at(-1)?.prompt_skill).toBeUndefined(); - }); - - // The first, never the last: a prompt that invokes two skills invoked the second from - // inside the first, and the prompt is named for the work it began. - it("keeps the first skill a prompt invoked when it invoked more than one", () => { - const content = chain([ - { type: "user", uuid: "u1", promptId: "p-abc" }, - { - type: "assistant", - uuid: "a1", - parentUuid: "u1", - sessionId: SID, - message: { - content: [ - { type: "tool_use", name: "Skill", input: { skill: "aidd-orchestrator:01-sdlc" } }, - ], - }, - }, - { - type: "assistant", - uuid: "a2", - parentUuid: "a1", - sessionId: SID, - message: { - content: [{ type: "tool_use", name: "Skill", input: { skill: "aidd-pm:04-spec" } }], - }, - }, - assistantLine({ uuid: "a3", parentUuid: "a2" }), - ]); - - const [record] = mapClaudeCodeTranscriptToSinkRecords(content); - - expect(record?.prompt_skill).toBe("aidd-orchestrator:01-sdlc"); - }); - - // Only a `Skill` call names a step; every other tool call is work inside the step already - // running, so reading one as a step start would name a skill the prompt never invoked. - it("ignores a tool call that is not a Skill call", () => { - const content = chain([ - { type: "user", uuid: "u1", promptId: "p-abc" }, - { - type: "assistant", - uuid: "a1", - parentUuid: "u1", - sessionId: SID, - message: { - content: [{ type: "tool_use", name: "Bash", input: { skill: "aidd-dev:01-plan" } }], - }, - }, - assistantLine({ uuid: "a2", parentUuid: "a1" }), - ]); - - const [record] = mapClaudeCodeTranscriptToSinkRecords(content); - - expect(record?.prompt_skill).toBeUndefined(); - }); - - // A transcript is appended to by a live process and can be truncated mid-write; a parent - // pointing at a line that never arrived must end the walk, not search forever. - it("stops at a parent the transcript does not hold, rather than looping", () => { - const content = chain([assistantLine({ uuid: "a1", parentUuid: "missing" })]); - - const [record] = mapClaudeCodeTranscriptToSinkRecords(content); - - expect(record?.prompt_id).toBeUndefined(); - }); - - // This one fails by hanging, not by going red: the walk is synchronous, so a cycle wedges the - // worker and no `--testTimeout` cuts it — removing the `seen` guard runs past two minutes. - it("terminates on a chain that points back at itself", () => { - const content = chain([ - { type: "user", uuid: "u1", parentUuid: "a1", promptId: undefined }, - assistantLine({ uuid: "a1", parentUuid: "u1" }), - ]); - - const [record] = mapClaudeCodeTranscriptToSinkRecords(content); - - expect(record?.prompt_id).toBeUndefined(); - }); -}); - -describe("mapClaudeCodeTranscriptToSinkRecords", () => { - it("yields one record per real assistant turn, by value, under the stored field names", () => { - const records = mapClaudeCodeTranscriptToSinkRecords(loadFixture(MAIN_PATH)); - - // The fixture holds a queue operation, a user turn and a tool_result turn (none carrying - // counters), a `` notice, and three real calls — one logged as two lines. - expect(records).toHaveLength(3); - expect(records[0]).toEqual({ - kind: "request", - vendor_id: SID, - vendor_field: "sessionId", - turn_id: "req_011Cdk8FcLJwNkFzLNRR8BpN", - turn_field: "requestId", - billed_request_id: "req_011Cdk8FcLJwNkFzLNRR8BpN", - model: "claude-sonnet-5", - effort: "high", - // The completed line's moment, not the one that opened the message: a billed request - // happened when it finished, and that is also the moment its day row is keyed on. - event_timestamp: "2026-08-05T19:07:15.789Z", - input_tokens: 2, - output_tokens: 184, - cache_read_tokens: 24436, - cache_creation_tokens: 18705, - }); - expect(records[1]).toMatchObject({ - turn_id: "req_011Cdk8GAKucdYdLHAXJU365", - output_tokens: 191, - }); - expect(records[2]).toMatchObject({ - turn_id: "req_011Cdk8GZ2QZU7DF2sXbhWSc", - output_tokens: 174, - }); - }); - - it("collapses two lines sharing one requestId into a single record, never doubling the call", () => { - const records = mapClaudeCodeTranscriptToSinkRecords(loadFixture(MAIN_PATH)); - - const forFirstCall = records.filter((r) => r.turn_id === "req_011Cdk8FcLJwNkFzLNRR8BpN"); - expect(forFirstCall).toHaveLength(1); - }); - - it("reads a subagent's own transcript file, attributing its work via agent_name", () => { - const records = mapClaudeCodeTranscriptToSinkRecords(loadFixture(SUBAGENT_PATH)); - - expect(records).toEqual([ - { - kind: "request", - vendor_id: SID, - vendor_field: "sessionId", - turn_id: "req_011Ce2HDaNo7CVCZKrT8yryX", - turn_field: "requestId", - billed_request_id: "req_011Ce2HDaNo7CVCZKrT8yryX", - model: "claude-opus-5", - effort: "high", - event_timestamp: "2026-08-14T07:54:15.988Z", - agent_name: "Explore", - // Read straight off the transcript with no journal beside it. No `step_plugin`: this - // line carries no `attributionPlugin`, and one is never invented beside a real skill. - step: "probe-echo", - input_tokens: 2, - output_tokens: 1, - cache_read_tokens: 0, - cache_creation_tokens: 20212, - }, - ]); - }); - - // Built by removing the real fixture's own attributionSkill key rather than hand-writing a - // payload, so this differs from the presence test above in that one field alone. - it("carries no step at all when a line has no attributionSkill, never asserting none ran", () => { - const withoutAttribution = loadFixture(SUBAGENT_PATH).replace( - /"attributionSkill":\s*"[^"]*",?/, - "" - ); - - const records = mapClaudeCodeTranscriptToSinkRecords(withoutAttribution); - - expect(records).toHaveLength(1); - expect(records[0] && "step" in records[0]).toBe(false); - expect(records[0] && "step_plugin" in records[0]).toBe(false); - }); - - it("keeps a subagent's counters distinct from the main line's — never merged into one figure", () => { - const mainRecords = mapClaudeCodeTranscriptToSinkRecords(loadFixture(MAIN_PATH)); - const subagentRecords = mapClaudeCodeTranscriptToSinkRecords(loadFixture(SUBAGENT_PATH)); - - const turnIds = new Set([...mainRecords, ...subagentRecords].map((r) => r.turn_id)); - expect(turnIds.size).toBe(mainRecords.length + subagentRecords.length); - expect(subagentRecords[0]?.agent_name).toBe("Explore"); - expect(mainRecords.every((r) => r.agent_name === undefined)).toBe(true); - }); - - it("skips a half-written final line rather than throwing", () => { - const content = loadFixture(MAIN_PATH); - const lastNewline = content.lastIndexOf("\n", content.length - 2); - const truncated = `${content.slice(0, lastNewline + 1)}${content.slice(lastNewline + 1, -40)}`; - - expect(() => mapClaudeCodeTranscriptToSinkRecords(truncated)).not.toThrow(); - const records = mapClaudeCodeTranscriptToSinkRecords(truncated); - expect(records).toHaveLength(2); - }); - - it("turns red rather than storing a zero when a counter field is renamed", () => { - const moved = loadFixture(MAIN_PATH).replaceAll( - "cache_creation_input_tokens", - "cacheCreationInputTokens" - ); - - const records = mapClaudeCodeTranscriptToSinkRecords(moved); - - expect(records).toHaveLength(0); - }); - - // The fixture's synthetic line is a captured one: a session-limit notice Claude Code composed - // itself, `model: ""`, four zero counters, its own `requestId`. - it("yields no record for a message the tool marked ", () => { - const records = mapClaudeCodeTranscriptToSinkRecords(loadFixture(MAIN_PATH)); - - expect(records.some((r) => r.model === "")).toBe(false); - expect(records.some((r) => r.turn_id === "req_011CdhNELnGn9e99rkVfSSSc")).toBe(false); - expect(records.map((r) => r.model)).toEqual([ - "claude-sonnet-5", - "claude-sonnet-5", - "claude-sonnet-5", - ]); - }); - - // The filter is the marker, not the symptom: all-counters-zero on a real model is improbable - // rather than impossible, and dropping it would lose a real call with nothing able to tell. - it("still yields a record for all-zero counters on a message that is not synthetic", () => { - const line = JSON.stringify({ - type: "assistant", - sessionId: SID, - requestId: "req_all_zero", - timestamp: "2026-08-05T19:08:00.000Z", - message: { - model: "claude-opus-5", - id: "msg_all_zero", - usage: { - input_tokens: 0, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - }); - - const records = mapClaudeCodeTranscriptToSinkRecords(line); - - expect(records).toHaveLength(1); - expect(records[0]).toMatchObject({ - turn_id: "req_all_zero", - model: "claude-opus-5", - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_creation_tokens: 0, - }); - }); - - // A skipped line must not claim the key either: the next real call sharing it would - // otherwise be dropped as a duplicate of something that was never a call. - it("leaves the dedupe key free for a real call sharing the synthetic line's requestId", () => { - const synthetic = JSON.stringify({ - type: "assistant", - sessionId: SID, - requestId: "req_shared", - message: { - model: "", - id: "msg_shared", - usage: { - input_tokens: 0, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - }); - const real = JSON.stringify({ - type: "assistant", - sessionId: SID, - requestId: "req_shared", - message: { - model: "claude-opus-5", - id: "msg_shared", - usage: { - input_tokens: 7, - output_tokens: 9, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - }); - - const records = mapClaudeCodeTranscriptToSinkRecords(`${synthetic}\n${real}\n`); - - expect(records).toHaveLength(1); - expect(records[0]).toMatchObject({ model: "claude-opus-5", output_tokens: 9 }); - }); - - it("touches no filesystem — a string in, an array out", () => { - expect(typeof mapClaudeCodeTranscriptToSinkRecords).toBe("function"); - expect(mapClaudeCodeTranscriptToSinkRecords.length).toBe(1); - }); -}); - -describe("createClaudeCodeTranscriptAccumulator", () => { - it("streamed one line at a time, matches the whole-content mapping", () => { - const whole = mapClaudeCodeTranscriptToSinkRecords(loadFixture(MAIN_PATH)); - const accumulator = createClaudeCodeTranscriptAccumulator(); - for (const line of loadFixture(MAIN_PATH).split("\n")) accumulator.push(line); - - expect(accumulator.build()).toEqual(whole); - }); - // Claude Code writes a line when a message starts and again when it completes, sharing one - // `message.id`; keeping the first kept a placeholder and discarded 37.4% of output tokens. - it("keeps the completed line for a message, not the placeholder that opened it", () => { - const shared = { - type: "assistant", - sessionId: "11111111-1111-4111-8111-111111111111", - requestId: "req_1", - timestamp: "2026-08-23T10:00:00.000Z", - }; - const opening = JSON.stringify({ - ...shared, - message: { - id: "msg_1", - model: "claude-opus-5", - usage: { - input_tokens: 2, - cache_creation_input_tokens: 37477, - cache_read_input_tokens: 0, - output_tokens: 3, - }, - }, - }); - const completed = JSON.stringify({ - ...shared, - message: { - id: "msg_1", - model: "claude-opus-5", - usage: { - input_tokens: 2, - cache_creation_input_tokens: 37477, - cache_read_input_tokens: 0, - output_tokens: 329, - output_tokens_details: { thinking_tokens: 49 }, - }, - }, - }); - - const records = mapClaudeCodeTranscriptToSinkRecords(`${opening}\n${completed}\n`); - - expect(records).toHaveLength(1); - expect(records[0].output_tokens).toBe(329); - // Never summed: the two lines are one call restated, so the cache counter must not grow. - expect(records[0].cache_creation_tokens).toBe(37477); - expect(records[0].input_tokens).toBe(2); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts deleted file mode 100644 index 5369556bd..000000000 --- a/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { readFileSync } from "node:fs"; -import { sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - createCodexRolloutAccumulator, - mapCodexRolloutToSinkRecords, -} from "../../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; -import { CODEX_ROLLOUT_LOCATION } from "../../../../../src/contexts/tools/domain/profiles/codex/codex-transcript-location.js"; -import { journalRecord } from "../../../../helpers/telemetry-journal-hook.js"; - -const TARGET_ID = "019fae6f-2009-7cd3-86b2-b8f83481b160"; -const TARGET_PARENT = "019f69d0-9e1f-7951-86c9-ddb23cfd51f4"; - -// Real, redacted rollout excerpts: target.jsonl is a resumed session (session_meta.id !== -// session_id), parent.jsonl its own parent, a fresh session where the two agree. -function loadFixture(relativePath: string): string { - const url = new URL(`../../../../fixtures/local-cost/${relativePath}`, import.meta.url); - return readFileSync(fileURLToPath(url), "utf8"); -} - -const TARGET_PATH = `.codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-${TARGET_ID}.jsonl`; -const PARENT_PATH = `.codex/sessions/2026/07/16/rollout-2026-07-16T09-25-07-${TARGET_PARENT}.jsonl`; - -describe("mapCodexRolloutToSinkRecords", () => { - it("yields one record per turn, its counters summed from the increments, never the totals", () => { - const records = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); - - // Real captured `last_token_usage` increments for this turn; summing `total_token_usage` - // instead would give an input figure over 8x too large. - expect(records).toHaveLength(2); - expect(records[0]).toEqual({ - kind: "request", - vendor_id: TARGET_ID, - vendor_field: "session_meta.id", - turn_id: "019fae6f-2084-7d63-b3c1-3d45d0864fe9", - turn_field: "turn_id", - model: "gpt-5.6-sol", - effort: "high", - event_timestamp: "2026-07-29T15:12:27.889Z", - input_tokens: 8898, - output_tokens: 827, - cache_read_tokens: 65792, - cache_creation_tokens: 0, - }); - expect(records[1]).toEqual({ - kind: "request", - vendor_id: TARGET_ID, - vendor_field: "session_meta.id", - turn_id: "019fae71-ae8b-7850-a982-78d7cd9dba52", - turn_field: "turn_id", - model: "gpt-5.6-sol", - effort: "high", - event_timestamp: "2026-07-29T15:15:13.692Z", - input_tokens: 5032, - output_tokens: 3550, - cache_read_tokens: 99840, - cache_creation_tokens: 0, - }); - }); - - it("resolves vendor_id from session_meta.id, not session_meta.session_id", () => { - const records = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); - - expect(records.every((r) => r.vendor_id === TARGET_ID)).toBe(true); - expect(records.every((r) => r.vendor_id !== TARGET_PARENT)).toBe(true); - }); - - it("carries the model and effort from turn_context, not from the counted event", () => { - // `token_count`'s own `info` carries no model and no effort, so a mapper reading either - // from there would leave them undefined on this fixture. - const records = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); - - expect(records.every((r) => r.model === "gpt-5.6-sol" && r.effort === "high")).toBe(true); - }); - - it("omits a counter never observed in any event of the turn, rather than summing a zero", () => { - // The parent fixture's events never carry cache_write_input_tokens at all, a real - // older-CLI shape: the record must have no key rather than a fabricated 0. - const [record] = mapCodexRolloutToSinkRecords(loadFixture(PARENT_PATH)); - - expect(record).toEqual({ - kind: "request", - vendor_id: TARGET_PARENT, - vendor_field: "session_meta.id", - turn_id: "019f69d1-8dcc-7272-a9eb-523ef9976475", - turn_field: "turn_id", - model: "gpt-5.5", - effort: "high", - event_timestamp: "2026-07-16T07:26:08.898Z", - input_tokens: 25073, - output_tokens: 1148, - cache_read_tokens: 22272, - }); - expect("cache_creation_tokens" in record).toBe(false); - }); - - it("turns red rather than storing a zero when last_token_usage is renamed", () => { - const moved = loadFixture(TARGET_PATH).replaceAll("last_token_usage", "lastTokenUsage"); - - expect(mapCodexRolloutToSinkRecords(moved)).toHaveLength(0); - }); - - // Without a moment a Codex record falls inside no step interval, and the journal is the - // only step source Codex has; the rollout carries that moment on its `turn_context` line. - it("carries the turn's own start, so a journal interval can reach it", () => { - const records = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); - - expect(records.length).toBeGreaterThan(0); - for (const record of records) { - expect(record.event_timestamp).toBeDefined(); - } - expect(records[0].event_timestamp).toBe("2026-07-29T15:12:27.889Z"); - }); - - it("takes the moment from turn_context, not from a counted event inside the turn", () => { - const records = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); - - // The `token_count` events of a turn arrive after it opens; a record covering the whole - // turn must not claim a moment inside it. - const [first] = records; - expect(first.event_timestamp).toBe("2026-07-29T15:12:27.889Z"); - expect(first.turn_id).toBe("019fae6f-2084-7d63-b3c1-3d45d0864fe9"); - }); - - it("touches no filesystem — a string in, an array out", () => { - expect(typeof mapCodexRolloutToSinkRecords).toBe("function"); - expect(mapCodexRolloutToSinkRecords.length).toBe(1); - }); -}); - -describe("createCodexRolloutAccumulator", () => { - it("streamed one line at a time, matches the whole-content mapping", () => { - const whole = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); - const accumulator = createCodexRolloutAccumulator(); - for (const line of loadFixture(TARGET_PATH).split("\n")) accumulator.push(line); - - expect(accumulator.build()).toEqual(whole); - }); -}); - -describe("CODEX_ROLLOUT_LOCATION", () => { - it("accepts a rollout for the id the journal hook derives from the same path", () => { - // The hook writes vendor_id and this location resolves the file to read; they agree only - // if both take the rollout's own id off the filename, and they can share no code. - for (const path of [TARGET_PATH, PARENT_PATH]) { - const derived = journalRecord.codexSessionIdFromTranscriptPath(path); - - expect(derived).toBeDefined(); - expect(CODEX_ROLLOUT_LOCATION.matches(path.split("/").join(sep), derived as string)).toBe( - true - ); - } - }); - - it("derives the resumed rollout's own id, never its parent's", () => { - // On a resumed session `session_meta.session_id` holds the parent's id, so a vendor_id - // written from it joins to nothing — 38% of measured Codex sessions are resumed. - expect(journalRecord.codexSessionIdFromTranscriptPath(TARGET_PATH)).toBe(TARGET_ID); - expect(journalRecord.codexSessionIdFromTranscriptPath(TARGET_PATH)).not.toBe(TARGET_PARENT); - }); - - it("derives nothing from a path that is not a rollout, so the payload's own spelling is used", () => { - expect(journalRecord.codexSessionIdFromTranscriptPath(undefined)).toBeUndefined(); - expect(journalRecord.codexSessionIdFromTranscriptPath("/tmp/notes.jsonl")).toBeUndefined(); - expect( - journalRecord.codexSessionIdFromTranscriptPath("rollout-no-uuid-here.jsonl") - ).toBeUndefined(); - }); - - it("falls back to the payload's session_id when no transcript path is carried", () => { - expect(journalRecord.readSessionId("codex", { session_id: "fallback-id" })).toBe("fallback-id"); - expect( - journalRecord.readSessionId("codex", { - session_id: "parent-id", - transcript_path: TARGET_PATH, - }) - ).toBe(TARGET_ID); - }); -}); - -// Measured on real rollouts: the last `token_count` of a turn is sometimes re-emitted -// verbatim, the same `last_token_usage` twice while `total_token_usage` does not move. -describe("a token_count re-emitted with an unmoved cumulative", () => { - const usage = (input: number, cached: number, output: number) => ({ - input_tokens: input, - cached_input_tokens: cached, - cache_write_input_tokens: 0, - output_tokens: output, - }); - const tokenCount = (last: object, total: object) => - JSON.stringify({ - type: "event_msg", - timestamp: "2026-08-05T09:42:40.000Z", - payload: { type: "token_count", info: { last_token_usage: last, total_token_usage: total } }, - }); - - const rollout = [ - JSON.stringify({ type: "session_meta", payload: { id: "s-1" } }), - JSON.stringify({ - type: "turn_context", - timestamp: "2026-08-05T09:42:34.000Z", - payload: { turn_id: "t-1", model: "gpt-5.4", effort: "high" }, - }), - tokenCount(usage(19468, 4000, 100), usage(19468, 4000, 100)), - tokenCount(usage(22086, 5000, 120), usage(41554, 9000, 220)), - // The re-emission: identical increment, and a cumulative that has not moved. - tokenCount(usage(22086, 5000, 120), usage(41554, 9000, 220)), - ].join("\n"); - - it("counts it once, because a cumulative that has not moved was never billed", () => { - const records = mapCodexRolloutToSinkRecords(rollout); - - expect(records).toHaveLength(1); - // input is made exclusive of cache: (19468-4000) + (22086-5000) = 32554. - expect(records[0]?.input_tokens).toBe(32554); - expect(records[0]?.cache_read_tokens).toBe(9000); - expect(records[0]?.output_tokens).toBe(220); - // And it reconciles with the cumulative the tool itself states at the turn's end. - expect((records[0]?.input_tokens ?? 0) + (records[0]?.cache_read_tokens ?? 0)).toBe(41554); - }); - - it("still counts an event that states no cumulative at all, rather than assuming nothing happened", () => { - const withoutTotal = [ - JSON.stringify({ type: "session_meta", payload: { id: "s-2" } }), - JSON.stringify({ - type: "turn_context", - timestamp: "2026-08-05T09:42:34.000Z", - payload: { turn_id: "t-2" }, - }), - tokenCount(usage(100, 0, 10), usage(100, 0, 10)), - JSON.stringify({ - type: "event_msg", - payload: { type: "token_count", info: { last_token_usage: usage(100, 0, 10) } }, - }), - ].join("\n"); - - expect(mapCodexRolloutToSinkRecords(withoutTotal)[0]?.output_tokens).toBe(20); - }); -}); - -const sessionMeta = (id: unknown) => JSON.stringify({ type: "session_meta", payload: { id } }); -const turnContext = (payload: Record, timestamp?: string) => - JSON.stringify({ type: "turn_context", timestamp, payload }); -const counted = (last: Record, total?: Record) => - JSON.stringify({ - type: "event_msg", - payload: { type: "token_count", info: { last_token_usage: last, total_token_usage: total } }, - }); -const rolloutOf = (...lines: string[]) => mapCodexRolloutToSinkRecords(lines.join("\n")); - -const BARE_TURN = { - kind: "request", - vendor_id: "s-1", - vendor_field: "session_meta.id", - turn_id: "t-1", - turn_field: "turn_id", -}; - -describe("a turn is recorded only once its session and turn are named", () => { - it("yields no record when session_meta.id is not a string", () => { - const records = rolloutOf( - sessionMeta(123), - turnContext({ turn_id: "t-1" }), - counted({ output_tokens: 3 }) - ); - - expect(records).toStrictEqual([]); - }); - - it("yields no record when no session_meta line names the session", () => { - const records = rolloutOf(turnContext({ turn_id: "t-1" }), counted({ output_tokens: 3 })); - - expect(records).toStrictEqual([]); - }); - - it("yields no record for a turn_context that names no turn_id", () => { - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ model: "gpt-5.4" }), - counted({ output_tokens: 3 }) - ); - - expect(records).toStrictEqual([]); - }); -}); - -describe("the record carries exactly the counters the turn stated", () => { - it.each([ - ["input_tokens", { input_tokens: 7 }, { input_tokens: 7 }], - ["output_tokens", { output_tokens: 3 }, { output_tokens: 3 }], - ["cached_input_tokens", { cached_input_tokens: 5 }, { cache_read_tokens: 5 }], - ["cache_write_input_tokens", { cache_write_input_tokens: 4 }, { cache_creation_tokens: 4 }], - ])("holds identity plus %s alone when that is all the turn stated", (_name, last, stored) => { - const records = rolloutOf(sessionMeta("s-1"), turnContext({ turn_id: "t-1" }), counted(last)); - - expect(records).toStrictEqual([{ ...BARE_TURN, ...stored }]); - }); - - it("leaves a counter unset rather than coercing a string figure", () => { - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ turn_id: "t-1" }), - counted({ input_tokens: "7", output_tokens: 3 }) - ); - - expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); - }); - - it("adds cache_write_input_tokens across the turn's events", () => { - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ turn_id: "t-1" }), - counted({ cache_write_input_tokens: 7 }), - counted({ cache_write_input_tokens: 5 }) - ); - - expect(records).toStrictEqual([{ ...BARE_TURN, cache_creation_tokens: 12 }]); - }); -}); - -describe("which lines count", () => { - it("counts both events when each states a cumulative with no figure in it", () => { - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ turn_id: "t-1" }), - counted({ output_tokens: 10 }, {}), - counted({ output_tokens: 10 }, {}) - ); - - expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 20 }]); - }); - - it("counts a re-emitted event once even when its unmoved cumulative omits a metric", () => { - const total = { input_tokens: 100, cached_input_tokens: 0, output_tokens: 10 }; - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ turn_id: "t-1" }), - counted({ output_tokens: 10 }, total), - counted({ output_tokens: 10 }, total) - ); - - expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 10 }]); - }); - - it("ignores a token_count carried by a line that is not an event_msg", () => { - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ turn_id: "t-1" }), - JSON.stringify({ - type: "response_item", - payload: { type: "token_count", info: { last_token_usage: { output_tokens: 3 } } }, - }) - ); - - expect(records).toStrictEqual([]); - }); - - it("ignores an event_msg that is not a token_count, even one carrying usage", () => { - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ turn_id: "t-1" }), - JSON.stringify({ - type: "event_msg", - payload: { type: "task_started", info: { last_token_usage: { output_tokens: 3 } } }, - }) - ); - - expect(records).toStrictEqual([]); - }); - - it("ignores a token_count that carries no info rather than throwing", () => { - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ turn_id: "t-1" }), - JSON.stringify({ type: "event_msg", payload: { type: "token_count" } }), - counted({ output_tokens: 3 }) - ); - - expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); - }); - - it("skips a half-written line rather than throwing", () => { - const records = rolloutOf( - sessionMeta("s-1"), - turnContext({ turn_id: "t-1" }), - '{"type":"event_msg","payload":{"type":"token_co', - counted({ output_tokens: 3 }) - ); - - expect(records).toStrictEqual([{ ...BARE_TURN, output_tokens: 3 }]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.integration.test.ts b/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.integration.test.ts deleted file mode 100644 index ca3c5d1cb..000000000 --- a/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.integration.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_TOKEN, - sessionTrailerDelegateScript, -} from "../../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; - -/** The delegate as git actually invokes it: only running the generated script proves what a - * `case` branch does with each `message_source`, which asserting on its text cannot. */ -const SESSION = "55555555-5555-4555-8555-555555555555"; - -const created: string[] = []; - -afterEach(async () => { - await Promise.all(created.map((dir) => rm(dir, { recursive: true, force: true }))); - created.length = 0; -}); - -/** Every variable the delegate reads, stripped before a session is added back - this suite - * runs inside a real Claude Code session, so a bare `process.env` already carries one. */ -function withoutSessionVariables(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const { CODEX_THREAD_ID, CLAUDE_CODE_SESSION_ID, ...rest } = env; - return rest; -} - -async function runDelegate( - message: string, - messageSource: string, - sessionEnv: NodeJS.ProcessEnv = {} -): Promise { - const dir = await mkdtemp(join(tmpdir(), "aidd-trailer-delegate-")); - created.push(dir); - const scriptPath = join(dir, "aidd-session-trailer.sh"); - const messagePath = join(dir, "MSG"); - await writeFile(scriptPath, sessionTrailerDelegateScript()); - await writeFile(messagePath, message); - - execFileSync("sh", [scriptPath, messagePath, messageSource], { - env: { ...withoutSessionVariables(process.env), ...sessionEnv }, - }); - - return readFile(messagePath, "utf8"); -} - -describe("the delegate's own case statement, run against each source git passes", () => { - it("trailers a merge a session resolved - that is session work, not a person authoring it", async () => { - const written = await runDelegate("merged", "merge", { CLAUDE_CODE_SESSION_ID: SESSION }); - - expect(written).toContain(`${SESSION_TRAILER_TOKEN}: ${SESSION}`); - }); - - it("trailers a squash a session produced, the same way", async () => { - const written = await runDelegate("squashed", "squash", { CLAUDE_CODE_SESSION_ID: SESSION }); - - expect(written).toContain(`${SESSION_TRAILER_TOKEN}: ${SESSION}`); - }); - - it("still writes nothing when no session made the commit", async () => { - const written = await runDelegate("by-hand", "message", {}); - - expect(written).not.toContain(SESSION_TRAILER_TOKEN); - }); - - it("never doubles a trailer a prior run already wrote", async () => { - const once = await runDelegate("merged", "merge", { CLAUDE_CODE_SESSION_ID: SESSION }); - - const twice = await runDelegate(once, "merge", { CLAUDE_CODE_SESSION_ID: SESSION }); - - expect(twice.split(SESSION_TRAILER_TOKEN).length - 1).toBe(1); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.unit.test.ts deleted file mode 100644 index 00f9265b1..000000000 --- a/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.unit.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerDelegateScript, - sessionTrailerHookLine, - sessionTrailerHuskyLine, - sessionTrailerLefthookJob, - sessionTrailerManagerSnippet, -} from "../../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; - -describe("the line added to a repository's own prepare-commit-msg", () => { - it("forwards git's own arguments, so the delegate always gets the message file it needs", () => { - expect(sessionTrailerHookLine("/repo/.git/hooks/aidd-session-trailer.sh")).toBe( - 'sh "/repo/.git/hooks/aidd-session-trailer.sh" "$@"' - ); - }); - - it("quotes the path, so a checkout living under a directory with a space still runs", () => { - const line = sessionTrailerHookLine("/Users/a b/repo/.git/hooks/x.sh"); - - expect(line).toContain('"/Users/a b/repo/.git/hooks/x.sh"'); - }); - - // A hook is shell, run by the `sh` Git for Windows ships, and that shell does not resolve - // `C:\Users\…`: inside double quotes a backslash is an ordinary character. - it("writes a Windows path with forward slashes, which is the only form sh resolves", () => { - const line = sessionTrailerHookLine("C:\\Users\\a\\repo\\.git\\hooks\\x.sh"); - - expect(line).toBe('sh "C:/Users/a/repo/.git/hooks/x.sh" "$@"'); - expect(line).not.toContain("\\"); - }); - - it("leaves a POSIX path exactly as it was", () => { - expect(sessionTrailerHookLine("/repo/.git/hooks/x.sh")).toBe('sh "/repo/.git/hooks/x.sh" "$@"'); - }); -}); - -describe("the delegate a commit's message actually passes through", () => { - const script = sessionTrailerDelegateScript(); - - it("reads Codex's own variable before Claude Code's, the precedence session-anchor.ts measured", () => { - // Shell parameter expansion, not a JS placeholder: this literal is what the delegate has - // to contain, character for character, so it is asserted as written. - // biome-ignore lint/suspicious/noTemplateCurlyInString: the string is shell, not JS - expect(script).toContain('session_id="${CODEX_THREAD_ID:-${CLAUDE_CODE_SESSION_ID:-}}"'); - }); - - it("writes nothing when no session made the commit - an unknown is never a guess", () => { - expect(script).toContain('[ -n "$session_id" ] || exit 0'); - }); - - it("no longer branches on message_source - a merge or a squash is session work too", () => { - expect(script).not.toContain("merge | squash"); - }); - - it("writes the trailer once however often it runs, amend included", () => { - expect(script).toContain("--if-exists doNothing"); - expect(script).toContain(`--trailer "${SESSION_TRAILER_TOKEN}=$session_id"`); - }); - - it("never fails a commit: every path out of it exits zero", () => { - const exits = script.match(/exit \d+/gu) ?? []; - - expect(exits.length).toBeGreaterThan(0); - expect(exits.every((line) => line === "exit 0")).toBe(true); - }); - - // Runs on every commit in the repository, long after whatever installed it. Depending on - // node, or on this CLI still being on PATH, would make an uninstall break commits. - it("needs nothing but a shell and git - it runs neither node nor this CLI", () => { - const instructions = script - .split("\n") - .filter((line) => line.trim() !== "" && !line.trimStart().startsWith("#")); - - expect(script.startsWith("#!/bin/sh\n")).toBe(true); - expect(instructions.some((line) => /\bnode\b/u.test(line))).toBe(false); - expect(instructions.some((line) => /\baidd\b/u.test(line))).toBe(false); - expect(instructions.some((line) => line.includes("git interpret-trailers"))).toBe(true); - }); - - it("names the commands that install and remove it, where a person will look", () => { - expect(script).toContain("aidd telemetry on"); - expect(script).toContain("aidd telemetry off"); - }); -}); - -describe("what the delegate is called on disk", () => { - it("is named for what it does, and is a shell script", () => { - expect(SESSION_TRAILER_DELEGATE_FILE).toBe("aidd-session-trailer.sh"); - }); -}); - -/** - * Neither snippet may carry an absolute path: these files are committed and shared across - * machines, so a path baked in on one contributor's disk names nothing on another's. - */ -describe("the job printed for a repository lefthook already owns", () => { - const job = sessionTrailerLefthookJob(SESSION_TRAILER_DELEGATE_FILE); - - it("is the prepare-commit-msg job, keyed the way lefthook.yml expects", () => { - expect(job).toContain("prepare-commit-msg:"); - }); - - it("forwards the message-file and source arguments with lefthook's own placeholders", () => { - expect(job).toContain("{1} {2}"); - }); - - it("only calls the delegate when it is actually there", () => { - expect(job).toContain("[ -f"); - }); - - it("carries no absolute path — resolved fresh against this machine's own git dir instead", () => { - expect(job).not.toMatch(/\/(Users|home)\//u); - expect(job).toContain("$(git rev-parse --git-common-dir)"); - }); -}); - -describe("the line printed for a repository husky already owns", () => { - const line = sessionTrailerHuskyLine(SESSION_TRAILER_DELEGATE_FILE); - - it("forwards git's own arguments the way a plain hook does", () => { - expect(line).toContain('"$@"'); - }); - - it("only calls the delegate when it is actually there", () => { - expect(line).toContain("[ -f"); - }); - - it("carries no absolute path either", () => { - expect(line).not.toMatch(/\/(Users|home)\//u); - expect(line).toContain("$(git rev-parse --git-common-dir)"); - }); -}); - -describe("sessionTrailerManagerSnippet — one place naming both the file and its snippet", () => { - it("names lefthook.yml for lefthook, carrying the same job", () => { - const result = sessionTrailerManagerSnippet("lefthook", SESSION_TRAILER_DELEGATE_FILE); - - expect(result.targetFile).toBe("lefthook.yml"); - expect(result.snippet).toBe(sessionTrailerLefthookJob(SESSION_TRAILER_DELEGATE_FILE)); - }); - - it("names .husky/prepare-commit-msg for husky, carrying the same line", () => { - const result = sessionTrailerManagerSnippet("husky", SESSION_TRAILER_DELEGATE_FILE); - - expect(result.targetFile).toBe(".husky/prepare-commit-msg"); - expect(result.snippet).toBe(sessionTrailerHuskyLine(SESSION_TRAILER_DELEGATE_FILE)); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts deleted file mode 100644 index 3faa654e0..000000000 --- a/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { mapCopilotEventsToSinkRecords } from "../../../../../src/contexts/telemetry/domain/formats/copilot-events.js"; - -const SESSION = "33333333-3333-4333-8333-333333333333"; -const EMPTY_SESSION = "44444444-4444-4444-8444-444444444444"; -const CACHED_SESSION = "55555555-5555-4555-8555-555555555555"; - -// Both fixtures are real, redacted excerpts of a captured Copilot session file — every -// message and reasoning field stripped. -function loadFixture(relativePath: string): string { - const url = new URL(`../../../../fixtures/local-cost/${relativePath}`, import.meta.url); - return readFileSync(fileURLToPath(url), "utf8"); -} - -const FULL_PATH = `.copilot/session-state/${SESSION}/events.jsonl`; -const EMPTY_PATH = `.copilot/session-state/${EMPTY_SESSION}/events.jsonl`; -const CACHED_PATH = `.copilot/session-state/${CACHED_SESSION}/events.jsonl`; - -describe("mapCopilotEventsToSinkRecords", () => { - it("yields one kind: session record, from session.shutdown's own tokenDetails", () => { - const records = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); - - expect(records).toEqual([ - { - kind: "session", - vendor_id: SESSION, - vendor_field: "sessionId", - turn_id: "99ccf9e7-b3ac-4145-a622-31852ec698cb", - turn_field: "id", - event_timestamp: "2026-08-21T14:07:49.286Z", - input_tokens: 10, - output_tokens: 42, - cache_read_tokens: 0, - cache_creation_tokens: 21070, - }, - ]); - }); - - it("stamps the vendor id it was given, never one read off the file's own content", () => { - // The file only ever confirms it holds *a* session, never which one, so the caller's - // own answer is the one that must win. - const records = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), "some-other-id"); - - expect(records[0]?.vendor_id).toBe("some-other-id"); - }); - - it("still yields a record from a truncated file with no session.start line at all", () => { - // A partial copy or a rotated file carries no session.start to fall back to; reading - // identity from the caller's own argument is what keeps the record from being dropped. - const noSessionStart = loadFixture(FULL_PATH) - .split("\n") - .filter((line) => !line.includes('"session.start"')) - .join("\n"); - - const records = mapCopilotEventsToSinkRecords(noSessionStart, SESSION); - - expect(records).toHaveLength(1); - expect(records[0]?.vendor_id).toBe(SESSION); - }); - - it("never reads modelMetrics.usage.inputTokens, which is inclusive of the cache figure", () => { - // Measured: 10 (tokenDetails.input) + 21070 (cache_write) = 21080 (usage.inputTokens). - const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); - - expect(record?.input_tokens).not.toBe(21080); - }); - - it("reads the four counters disjoint when the cached prompt is large, the case left open", () => { - // Measured: 9 + 42038 + 21404 = 63451 = usage.inputTokens. At cache_read 0 an input that - // excludes the cached prompt and one that includes it read alike; at 42038 they cannot. - const [record] = mapCopilotEventsToSinkRecords(loadFixture(CACHED_PATH), CACHED_SESSION); - - expect(record?.input_tokens).toBe(9); - expect(record?.cache_read_tokens).toBe(42038); - expect(record?.cache_creation_tokens).toBe(21404); - expect( - (record?.input_tokens ?? 0) + - (record?.cache_read_tokens ?? 0) + - (record?.cache_creation_tokens ?? 0) - ).toBe(63451); - }); - - it("never carries cost_usd — totalPremiumRequests is a multiplier, not a currency", () => { - const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); - - expect(record && "cost_usd" in record).toBe(false); - }); - - it("never names a model — currentModel is only ever the session's last model", () => { - const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); - - expect(record && "model" in record).toBe(false); - }); - - it("yields nothing, not a record of zeros, when shutdown carried no tokenDetails", () => { - const records = mapCopilotEventsToSinkRecords(loadFixture(EMPTY_PATH), EMPTY_SESSION); - - expect(records).toEqual([]); - }); - - it("yields nothing for a session that never shut down", () => { - const noShutdown = loadFixture(FULL_PATH) - .split("\n") - .filter((line) => !line.includes('"session.shutdown"')) - .join("\n"); - - expect(mapCopilotEventsToSinkRecords(noShutdown, SESSION)).toEqual([]); - }); - - it("turns red rather than storing a zero when tokenDetails' own field is renamed", () => { - const moved = loadFixture(FULL_PATH).replaceAll("tokenCount", "token_count"); - - expect(mapCopilotEventsToSinkRecords(moved, SESSION)).toEqual([]); - }); - - it("touches no filesystem — two strings in, an array out", () => { - expect(typeof mapCopilotEventsToSinkRecords).toBe("function"); - expect(mapCopilotEventsToSinkRecords.length).toBe(2); - }); -}); - -const TOKEN_DETAILS = { - input: { tokenCount: 10 }, - output: { tokenCount: 42 }, - cache_read: { tokenCount: 0 }, - cache_write: { tokenCount: 21070 }, -}; - -function shutdown(line: Record): string { - return JSON.stringify({ type: "session.shutdown", ...line }); -} - -describe("a shutdown counts only when every counter is stated as a number", () => { - it.each(["input", "output", "cache_read", "cache_write"] as const)( - "yields nothing, without throwing, when tokenDetails lacks %s", - (missing) => { - const { [missing]: _dropped, ...tokenDetails } = TOKEN_DETAILS; - - expect(mapCopilotEventsToSinkRecords(shutdown({ data: { tokenDetails } }), SESSION)).toEqual( - [] - ); - } - ); - - it("yields nothing when a counter is a string rather than a number", () => { - const tokenDetails = { ...TOKEN_DETAILS, input: { tokenCount: "10" } }; - - expect(mapCopilotEventsToSinkRecords(shutdown({ data: { tokenDetails } }), SESSION)).toEqual( - [] - ); - }); - - it("yields nothing for a shutdown that carries no data at all", () => { - expect(mapCopilotEventsToSinkRecords(shutdown({}), SESSION)).toEqual([]); - }); -}); - -describe("the record carries exactly the keys the shutdown states", () => { - it("holds identity and counters alone when the shutdown names no turn and no moment", () => { - const content = shutdown({ id: 7, data: { tokenDetails: TOKEN_DETAILS } }); - - expect(mapCopilotEventsToSinkRecords(content, SESSION)).toStrictEqual([ - { - kind: "session", - vendor_id: SESSION, - vendor_field: "sessionId", - input_tokens: 10, - output_tokens: 42, - cache_read_tokens: 0, - cache_creation_tokens: 21070, - }, - ]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/formats/local-cost-fixtures.redaction.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/local-cost-fixtures.redaction.unit.test.ts deleted file mode 100644 index f3f95c7cf..000000000 --- a/cli/tests/contexts/telemetry/domain/formats/local-cost-fixtures.redaction.unit.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -// Scans tests/fixtures/local-cost/ itself, not a named list of files — a fixture added -// later by this test's own module or by a future tool reader is covered automatically. -const FIXTURES_DIR = fileURLToPath(new URL("../../../../fixtures/local-cost", import.meta.url)); - -// Keys no counter-bearing line ever needs: each carried a real prompt, file path, -// credential-adjacent detail or system-prompt-sized blob in the transcripts excerpted here. -const FORBIDDEN_KEYS = [ - "cwd", - "workspace_roots", - "gitBranch", - "developer_instructions", - "signature", - "rate_limits", - "base_instructions", - "git", - "version", -]; - -const MAX_STRING_LENGTH = 200; -const REDACTED_PLACEHOLDER = "[REDACTED]"; -const ABSOLUTE_PATH_RE = /^\/(Users|private|home)\//; -const EMAIL_RE = /[^\s@]+@[^\s@]+\.[^\s@]+/; - -function listFiles(dir: string): string[] { - const files: string[] = []; - for (const entry of readdirSync(dir)) { - const full = join(dir, entry); - if (statSync(full).isDirectory()) files.push(...listFiles(full)); - else files.push(full); - } - return files; -} - -function walkValues( - value: unknown, - onString: (s: string) => void, - onKey: (k: string) => void -): void { - if (typeof value === "string") { - onString(value); - return; - } - if (Array.isArray(value)) { - for (const item of value) walkValues(item, onString, onKey); - return; - } - if (value && typeof value === "object") { - for (const [key, item] of Object.entries(value)) { - onKey(key); - walkValues(item, onString, onKey); - } - } -} - -describe("tests/fixtures/local-cost — no fixture carries prompt, response, or file content", () => { - const files = listFiles(FIXTURES_DIR); - - it("finds at least the four known fixtures — the scan itself is not vacuous", () => { - expect(files.length).toBeGreaterThanOrEqual(4); - }); - - it.each(files.map((f) => [f.replace(`${FIXTURES_DIR}/`, ""), f] as const))( - "%s carries no forbidden key, absolute path, email, or oversized string", - (_label, file) => { - const lines = readFileSync(file, "utf8") - .split("\n") - .filter((l) => l.trim()); - for (const line of lines) { - const parsed: unknown = JSON.parse(line); - walkValues( - parsed, - (s) => { - expect(s).not.toMatch(ABSOLUTE_PATH_RE); - expect(s).not.toMatch(EMAIL_RE); - if (s.length > MAX_STRING_LENGTH) expect(s).toBe(REDACTED_PLACEHOLDER); - }, - (k) => { - expect(FORBIDDEN_KEYS, `${file} carries forbidden key "${k}"`).not.toContain(k); - } - ); - } - } - ); -}); diff --git a/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts deleted file mode 100644 index 21875dadb..000000000 --- a/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { mapOpencodeExportToSinkRecords } from "../../../../../src/contexts/telemetry/domain/formats/opencode-export.js"; - -const SESSION_ID = "ses_test_read"; - -// A real `opencode export --sanitize` capture (opencode 1.14.20), mechanically trimmed to -// `{info, messages: [{info}, ...]}`. No value inside `info` was hand-edited. -function loadFixture(name: string): unknown { - const url = new URL(`../../../../fixtures/telemetry-sink/${name}`, import.meta.url); - return JSON.parse(readFileSync(fileURLToPath(url), "utf8")); -} - -describe("mapOpencodeExportToSinkRecords", () => { - // Measured on a non-Anthropic capture: `input` falls from 28242 to 269 as `cache.read` - // climbs to 28928, and `total` only adds up if the counters are disjoint. - it("reads a non-Anthropic provider's counters as disjoint, the comparison no capture held", () => { - const records = mapOpencodeExportToSinkRecords( - loadFixture("opencode-export-non-anthropic-cache.json"), - "ses_test_non_anthropic" - ); - - expect( - records.map((record) => ({ - input: record.input_tokens, - output: record.output_tokens, - cacheRead: record.cache_read_tokens, - cacheCreation: record.cache_creation_tokens, - })) - ).toEqual([ - { input: 28242, output: 193, cacheRead: 640, cacheCreation: 0 }, - { input: 269, output: 137, cacheRead: 28928, cacheCreation: 0 }, - { input: 196, output: 56, cacheRead: 29184, cacheCreation: 0 }, - ]); - expect(records.every((record) => record.model === "ling-3.0-flash-fin-free")).toBe(true); - }); - - it("yields one record per billed message, by value, under the stored field names", () => { - const records = mapOpencodeExportToSinkRecords(loadFixture("opencode-export.json"), SESSION_ID); - - // The fixture holds 5 user turns, 3 billed assistant turns, and 1 assistant turn - // OpenCode created but never billed — only the billed ones are counted messages. - expect(records).toHaveLength(3); - expect(records).toEqual([ - { - kind: "request", - vendor_id: SESSION_ID, - vendor_field: "sessionID", - turn_id: "msg_cf515b1b20011NzmARPrSpI1lW", - turn_field: "id", - model: "claude-sonnet-4-6", - event_timestamp: "2026-03-16T05:19:25.618Z", - input_tokens: 3, - output_tokens: 115, - cache_read_tokens: 43639, - cache_creation_tokens: 3141, - }, - { - kind: "request", - vendor_id: SESSION_ID, - vendor_field: "sessionID", - turn_id: "msg_cf515c482001XcMRpKRNVBj0v9", - turn_field: "id", - model: "claude-sonnet-4-6", - event_timestamp: "2026-03-16T05:19:30.434Z", - input_tokens: 1, - output_tokens: 238, - cache_read_tokens: 46780, - cache_creation_tokens: 176, - }, - { - kind: "request", - vendor_id: SESSION_ID, - vendor_field: "sessionID", - turn_id: "msg_cf515d659001v8AyNXNm4y69T8", - turn_field: "id", - model: "claude-sonnet-4-6", - event_timestamp: "2026-03-16T05:19:35.001Z", - input_tokens: 1, - output_tokens: 161, - cache_read_tokens: 46956, - cache_creation_tokens: 4074, - }, - ]); - }); - - it("yields no record for a message OpenCode created but never billed — no total, even though tokens is present and every counter reads 0", () => { - const payload = { - messages: [ - { - info: { - role: "assistant", - id: "msg_aborted", - sessionID: SESSION_ID, - time: { created: 1773638379047 }, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }, - }, - ], - }; - - expect(mapOpencodeExportToSinkRecords(payload, SESSION_ID)).toEqual([]); - }); - - it("keeps the record for a billed message that genuinely used 0 tokens — total is present, even at 0", () => { - const payload = { - messages: [ - { - info: { - role: "assistant", - id: "msg_zero_billed", - sessionID: SESSION_ID, - time: { created: 1773638379047 }, - tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }, - }, - ], - }; - - expect(mapOpencodeExportToSinkRecords(payload, SESSION_ID)).toEqual([ - { - kind: "request", - vendor_id: SESSION_ID, - vendor_field: "sessionID", - turn_id: "msg_zero_billed", - turn_field: "id", - event_timestamp: "2026-03-16T05:19:39.047Z", - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_creation_tokens: 0, - }, - ]); - }); - - it("sets vendor_id from the sessionId argument, never from the payload's own id", () => { - const records = mapOpencodeExportToSinkRecords(loadFixture("opencode-export.json"), "s-other"); - - expect(records.every((r) => r.vendor_id === "s-other")).toBe(true); - }); - - it("never reads info.cost — it is 0 with no established denomination", () => { - const records = mapOpencodeExportToSinkRecords(loadFixture("opencode-export.json"), SESSION_ID); - - for (const record of records) { - expect(record.cost_usd).toBeUndefined(); - } - }); - - it("yields no record for a message with no counters, never an invented zero", () => { - const payload = { messages: [{ info: { role: "user", id: "msg_1" } }] }; - - expect(mapOpencodeExportToSinkRecords(payload, SESSION_ID)).toEqual([]); - }); - - it("returns nothing for an empty or malformed payload rather than throwing", () => { - expect(mapOpencodeExportToSinkRecords({}, SESSION_ID)).toEqual([]); - expect(mapOpencodeExportToSinkRecords(null, SESSION_ID)).toEqual([]); - expect(mapOpencodeExportToSinkRecords({ messages: [] }, SESSION_ID)).toEqual([]); - }); - - it("skips a null message entry rather than throwing", () => { - expect(mapOpencodeExportToSinkRecords({ messages: [null] }, SESSION_ID)).toEqual([]); - }); -}); - -const BARE_MESSAGE = { - kind: "request", - vendor_id: SESSION_ID, - vendor_field: "sessionID", - turn_id: "msg_1", - turn_field: "id", -}; - -function recordsOf(info: Record) { - return mapOpencodeExportToSinkRecords( - { messages: [{ info: { id: "msg_1", ...info } }] }, - SESSION_ID - ); -} - -describe("the record carries exactly the keys the message states", () => { - it("holds identity alone when a billed message states no counter, model or time", () => { - expect(recordsOf({ tokens: { total: 5 } })).toStrictEqual([BARE_MESSAGE]); - }); - - it("reads a message whose tokens carry no cache block, without throwing", () => { - expect(recordsOf({ tokens: { total: 5, input: 3, output: 2 } })).toStrictEqual([ - { ...BARE_MESSAGE, input_tokens: 3, output_tokens: 2 }, - ]); - }); - - it("leaves a counter unset rather than storing a string figure", () => { - expect(recordsOf({ tokens: { total: 5, input: "3", output: 2 } })).toStrictEqual([ - { ...BARE_MESSAGE, output_tokens: 2 }, - ]); - }); - - it("names no model and no turn when neither is a string", () => { - const records = mapOpencodeExportToSinkRecords( - { messages: [{ info: { id: 42, modelID: 42, tokens: { total: 5 } } }] }, - SESSION_ID - ); - - expect(records).toStrictEqual([ - { kind: "request", vendor_id: SESSION_ID, vendor_field: "sessionID" }, - ]); - }); - - it.each([0, -1])("carries no moment for a creation time of %s", (created) => { - expect(recordsOf({ tokens: { total: 5 }, time: { created } })).toStrictEqual([BARE_MESSAGE]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts b/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts deleted file mode 100644 index 28c29a042..000000000 --- a/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildClosedIntervals, - type IntervalClosure, - momentFallsWithin, - timed, -} from "../../../../src/contexts/telemetry/domain/journal-intervals.js"; - -interface Boundary { - readonly at: string; - readonly kind: "open" | "close" | "other"; -} - -interface Interval { - readonly startMs: number; - readonly endMs: number; - readonly closedBy: IntervalClosure; -} - -const isOpener = (boundary: Boundary): boundary is Boundary => boundary.kind === "open"; -const isCloser = (boundary: Boundary): boolean => boundary.kind === "close"; -const toInterval = ( - _opener: Boundary, - startMs: number, - endMs: number, - closedBy: IntervalClosure -): Interval => ({ startMs, endMs, closedBy }); - -describe("timed()", () => { - it("drops a boundary whose own at cannot be parsed, instead of leaving a mid-list gap", () => { - const result = timed([ - { at: "2026-01-01T00:00:00.000Z" }, - { at: "not-a-date" }, - { at: "2026-01-02T00:00:00.000Z" }, - ]); - - expect(result.map((entry) => entry.boundary.at)).toEqual([ - "2026-01-01T00:00:00.000Z", - "2026-01-02T00:00:00.000Z", - ]); - }); - - it("sorts by the parsed moment, whatever order the input carried", () => { - const result = timed([{ at: "2026-01-02T00:00:00.000Z" }, { at: "2026-01-01T00:00:00.000Z" }]); - - expect(result.map((entry) => entry.atMs)).toEqual([ - Date.parse("2026-01-01T00:00:00.000Z"), - Date.parse("2026-01-02T00:00:00.000Z"), - ]); - }); -}); - -describe("buildClosedIntervals — the periodEndMs cap", () => { - it("caps an unclosed interval's end at periodEndMs, never at a later moment the journal witnessed", () => { - const opensAt = Date.parse("2026-01-01T00:00:00.000Z"); - const periodEndMs = Date.parse("2026-01-02T00:00:00.000Z"); - const boundaries: readonly Boundary[] = [ - { at: "2026-01-01T00:00:00.000Z", kind: "open" }, - { at: "2026-01-05T00:00:00.000Z", kind: "other" }, - ]; - - const intervals = buildClosedIntervals(boundaries, periodEndMs, isOpener, isCloser, toInterval); - - expect(intervals).toEqual([{ startMs: opensAt, endMs: periodEndMs, closedBy: "journal-end" }]); - }); -}); - -describe("IntervalClosure — the three ways an interval's end is reached", () => { - it("closes on an explicit closer boundary", () => { - const boundaries: readonly Boundary[] = [ - { at: "2026-01-01T00:00:00.000Z", kind: "open" }, - { at: "2026-01-02T00:00:00.000Z", kind: "close" }, - ]; - - const intervals = buildClosedIntervals(boundaries, undefined, isOpener, isCloser, toInterval); - - expect(intervals).toEqual([ - { - startMs: Date.parse("2026-01-01T00:00:00.000Z"), - endMs: Date.parse("2026-01-02T00:00:00.000Z"), - closedBy: "boundary", - }, - ]); - }); - - it("closes on the next opener, even with no explicit closer in between", () => { - const boundaries: readonly Boundary[] = [ - { at: "2026-01-01T00:00:00.000Z", kind: "open" }, - { at: "2026-01-02T00:00:00.000Z", kind: "open" }, - ]; - - const intervals = buildClosedIntervals(boundaries, undefined, isOpener, isCloser, toInterval); - - expect(intervals[0]).toEqual({ - startMs: Date.parse("2026-01-01T00:00:00.000Z"), - endMs: Date.parse("2026-01-02T00:00:00.000Z"), - closedBy: "boundary", - }); - }); - - it("stays open to the journal's own last witnessed moment when nothing closes it", () => { - const boundaries: readonly Boundary[] = [ - { at: "2026-01-01T00:00:00.000Z", kind: "open" }, - { at: "2026-01-03T00:00:00.000Z", kind: "other" }, - ]; - - const intervals = buildClosedIntervals(boundaries, undefined, isOpener, isCloser, toInterval); - - expect(intervals).toEqual([ - { - startMs: Date.parse("2026-01-01T00:00:00.000Z"), - endMs: Date.parse("2026-01-03T00:00:00.000Z"), - closedBy: "journal-end", - }, - ]); - }); -}); - -describe("momentFallsWithin — over more than one interval", () => { - const FIRST = { - startMs: Date.parse("2026-01-01T00:00:00.000Z"), - endMs: Date.parse("2026-01-02T00:00:00.000Z"), - }; - const SECOND = { - startMs: Date.parse("2026-01-05T00:00:00.000Z"), - endMs: Date.parse("2026-01-06T00:00:00.000Z"), - }; - - it("holds for a moment inside one interval alone", () => { - expect(momentFallsWithin([FIRST, SECOND], "2026-01-05T12:00:00.000Z")).toBe(true); - }); - - it("holds for a moment at the very instant an interval opens", () => { - expect(momentFallsWithin([FIRST, SECOND], "2026-01-05T00:00:00.000Z")).toBe(true); - }); - - it("fails for a moment in the gap between two intervals", () => { - expect(momentFallsWithin([FIRST, SECOND], "2026-01-03T00:00:00.000Z")).toBe(false); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts b/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts deleted file mode 100644 index 259256c5e..000000000 --- a/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { describe, expect, it } from "vitest"; -import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; -// Side-effect imports: the use-case resolves every AI tool's local-read declaration from -// the registry, so every tool must be registered for the worked example to run at all. -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; -import type { LocalCostCandidateRecord } from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; -import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; -import { NULL_RUN_JOURNAL_READER } from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -// Trusts no hand-maintained list of fields on either side: only the record's own interface -// text and the document's own prose, both read fresh off disk. - -const RECORD_MODEL_URL = new URL( - "../../../../src/contexts/telemetry/domain/telemetry-sink-record.ts", - import.meta.url -); -const CONTRACT_DOC_URL = pathToFileURL( - join(REPOSITORY_ROOT, "aidd_docs", "product", "metrics-contract.md") -); -function readTextFile(url: URL): string { - return readFileSync(fileURLToPath(url), "utf8"); -} - -/** Every field name on `TelemetrySinkRecord`, read straight off the interface's own text, - * so an added or removed field is seen here without this file being told about it. */ -function recordFieldNames(): readonly string[] { - const source = readTextFile(RECORD_MODEL_URL); - const start = source.indexOf("export interface TelemetrySinkRecord {"); - if (start === -1) throw new Error("TelemetrySinkRecord interface not found in source"); - const end = source.indexOf("\n}", start); - const body = source.slice(start, end); - const fields = [...body.matchAll(/^\s*readonly\s+([a-zA-Z0-9_]+)\??:/gm)].map((m) => m[1]); - if (fields.length === 0) - throw new Error("no fields parsed from TelemetrySinkRecord — regex drifted"); - return fields; -} - -/** Every field name the "## Field reference" section documents, one heading per field (a - * heading may name more than one field, for the four token counters sharing one entry). */ -function documentedFieldNames(): readonly string[] { - const doc = readTextFile(CONTRACT_DOC_URL); - const sectionStart = doc.indexOf("## Field reference"); - if (sectionStart === -1) - throw new Error('"## Field reference" section not found in the contract doc'); - const nextSection = doc.indexOf("\n## ", sectionStart + 1); - const section = - nextSection === -1 ? doc.slice(sectionStart) : doc.slice(sectionStart, nextSection); - const headings = [...section.matchAll(/^####\s+(.+)$/gm)].map((m) => m[1]); - if (headings.length === 0) throw new Error("no #### field headings parsed from the contract doc"); - const fields = headings.flatMap((heading) => - [...heading.matchAll(/`([a-zA-Z0-9_]+)`/g)].map((m) => m[1]) - ); - if (fields.length === 0) throw new Error("no backticked field names parsed from #### headings"); - return fields; -} - -describe("metrics contract vs. TelemetrySinkRecord", () => { - it("documents every field the record actually has", () => { - const recordFields = new Set(recordFieldNames()); - const documentedFields = new Set(documentedFieldNames()); - const undocumented = [...recordFields].filter((f) => !documentedFields.has(f)); - expect( - undocumented, - `record field(s) missing from the contract doc: ${undocumented.join(", ")}` - ).toEqual([]); - }); - - it("never documents a field the record no longer has", () => { - const recordFields = new Set(recordFieldNames()); - const documentedFields = new Set(documentedFieldNames()); - const stale = [...documentedFields].filter((f) => !recordFields.has(f)); - expect(stale, `documented field(s) no longer on the record: ${stale.join(", ")}`).toEqual([]); - }); -}); - -describe("metrics contract worked example: a re-read appends unless matched", () => { - function docTokenFigures(): { - readonly input: number; - readonly output: number; - readonly stored: number; - readonly wouldHaveBeen: number; - } { - const doc = readTextFile(CONTRACT_DOC_URL); - const setup = doc.match(/carries (\d+) input tokens and (\d+) output tokens/); - if (!setup) throw new Error("worked-example token setup not found in doc"); - const result = doc.match(/Stored total after the second read: (\d+) tokens, not\s+(\d+)\./); - if (!result) throw new Error("worked-example stored-total sentence not found in doc"); - return { - input: Number(setup[1]), - output: Number(setup[2]), - stored: Number(result[1]), - wouldHaveBeen: Number(result[2]), - }; - } - - it("recomputes the matched-turn_id total via ReadLocalCostUseCase, and it matches the doc", async () => { - const { input, output, stored } = docTokenFigures(); - const candidate: LocalCostCandidateRecord = { - kind: "request", - vendor_id: "s-doc-example", - vendor_field: "sessionId", - turn_id: "req_1", - turn_field: "requestId", - input_tokens: input, - output_tokens: output, - }; - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", { read: async () => ({ records: [candidate], sessionFound: true }) }]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - new StubTelemetryEvidenceReader() - ); - - await useCase.execute({ projectRoot: "/repo", env: {}, sessionId: "s-doc-example" }); - await useCase.execute({ projectRoot: "/repo", env: {}, sessionId: "s-doc-example" }); // the re-read - - const storedRecords = [...sink.files.values()].flat(); - const total = storedRecords.reduce( - (sum, r) => sum + (r.input_tokens ?? 0) + (r.output_tokens ?? 0), - 0 - ); - expect(total).toBe(stored); - }); - - it("recomputes the unmatched (no turn_id) total, and it matches the doc's counterfactual", async () => { - const { input, output, wouldHaveBeen } = docTokenFigures(); - const candidateWithNoTurnId: LocalCostCandidateRecord = { - kind: "request", - vendor_id: "s-doc-example-2", - vendor_field: "sessionId", - input_tokens: input, - output_tokens: output, - }; - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { read: async () => ({ records: [candidateWithNoTurnId], sessionFound: true }) }, - ], - ]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - new StubTelemetryEvidenceReader() - ); - - await useCase.execute({ projectRoot: "/repo", env: {}, sessionId: "s-doc-example-2" }); - await useCase.execute({ projectRoot: "/repo", env: {}, sessionId: "s-doc-example-2" }); // the re-read, unmatched - - const storedRecords = [...sink.files.values()].flat(); - const total = storedRecords.reduce( - (sum, r) => sum + (r.input_tokens ?? 0) + (r.output_tokens ?? 0), - 0 - ); - expect(total).toBe(wouldHaveBeen); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts b/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts deleted file mode 100644 index 9ea401edd..000000000 --- a/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolvePerson, - withAlsoMeAdded, - withPersonIdAdopted, -} from "../../../../src/contexts/telemetry/domain/person-resolution.js"; -import type { PersonIdentity } from "../../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; - -function identityWithAlsoMe(): PersonIdentity { - return { - personId: "person-a", - origin: "adopted", - alsoMe: ["claude-machine-1", "codex-machine-2"], - displayName: "Ada", - }; -} - -describe("resolvePerson", () => { - it("resolves an identity listed under alsoMe to this person's canonical identifier", () => { - const resolved = resolvePerson(identityWithAlsoMe(), "claude-machine-1"); - - expect(resolved.resolution).toBe("mapped"); - expect(resolved.personId).toBe("person-a"); - expect(resolved.displayName).toBe("Ada"); - }); - - it("resolves this person's own canonical identifier to that same person", () => { - const resolved = resolvePerson(identityWithAlsoMe(), "person-a"); - - expect(resolved.resolution).toBe("mapped"); - expect(resolved.personId).toBe("person-a"); - }); - - it("resolves an identifier nobody added as unresolved", () => { - const resolved = resolvePerson(identityWithAlsoMe(), "nobody-claimed-this"); - - expect(resolved.resolution).toBe("unresolved"); - expect(resolved.personId).toBeUndefined(); - expect(resolved.identities).toEqual(["nobody-claimed-this"]); - }); - - // `person_id` is stamped when a record is stored, so whether a record carries one depends on - // when the identity was declared. Every sink line is `local-read`, by this machine's reader. - it("names an unstamped record after this machine's own declared identity", () => { - const resolved = resolvePerson(identityWithAlsoMe(), undefined); - - expect(resolved.resolution).toBe("this-machine"); - expect(resolved.personId).toBe("person-a"); - expect(resolved.displayName).toBe("Ada"); - }); - - // Same evidence a mapped row carries, for the same reason: a person line must be - // traceable back to what produced it without a second lookup. - it("carries this identity's own identifiers as the evidence behind that row", () => { - const resolved = resolvePerson(identityWithAlsoMe(), undefined); - - expect(resolved.identities).toEqual(["person-a", "claude-machine-1", "codex-machine-2"]); - }); - - it("resolves nothing at all as none when no identity was ever declared", () => { - const resolved = resolvePerson(null, undefined); - - expect(resolved.resolution).toBe("none"); - expect(resolved.identities).toEqual([]); - }); - - // The fallback must never overrule what a record actually said. An identifier this - // identity does not claim stays unresolved, and one it does claim stays mapped. - it("never lets the fallback overrule an identifier the record carried", () => { - const unresolved = resolvePerson(identityWithAlsoMe(), "nobody-claimed-this"); - const mapped = resolvePerson(identityWithAlsoMe(), "claude-machine-1"); - - expect(unresolved.resolution).toBe("unresolved"); - expect(mapped.resolution).toBe("mapped"); - }); - - it("distinguishes none, this-machine and unresolved from one another", () => { - const none = resolvePerson(null, undefined); - const thisMachine = resolvePerson(identityWithAlsoMe(), undefined); - const unresolved = resolvePerson(identityWithAlsoMe(), "nobody-claimed-this"); - - expect(new Set([none.resolution, thisMachine.resolution, unresolved.resolution]).size).toBe(3); - }); - - it("two identifiers nobody added stay distinct, never merged into one bucket", () => { - const first = resolvePerson(identityWithAlsoMe(), "unplaced-one"); - const second = resolvePerson(identityWithAlsoMe(), "unplaced-two"); - - expect(first.resolution).toBe("unresolved"); - expect(second.resolution).toBe("unresolved"); - expect(first.identities).toEqual(["unplaced-one"]); - expect(second.identities).toEqual(["unplaced-two"]); - expect(first.identities).not.toEqual(second.identities); - }); - - it("a null identity resolves any given identifier as unresolved, still naming it", () => { - const resolved = resolvePerson(null, "claude-machine-1"); - - expect(resolved.resolution).toBe("unresolved"); - expect(resolved.identities).toEqual(["claude-machine-1"]); - }); - - it("a null identity still resolves an absent identifier as none", () => { - expect(resolvePerson(null, undefined).resolution).toBe("none"); - }); - - it("reads an empty identifier as none carried, naming this machine's own person", () => { - expect(resolvePerson(identityWithAlsoMe(), "")).toStrictEqual({ - resolution: "this-machine", - personId: "person-a", - displayName: "Ada", - identities: ["person-a", "claude-machine-1", "codex-machine-2"], - }); - }); - - it("reads an empty identifier against no identity as none, never as an unresolved blank", () => { - expect(resolvePerson(null, "")).toStrictEqual({ resolution: "none", identities: [] }); - }); - - it("a resolved person carries back every identity behind it, including its canonical one", () => { - const resolved = resolvePerson(identityWithAlsoMe(), "codex-machine-2"); - - expect(resolved.identities).toEqual( - expect.arrayContaining(["person-a", "claude-machine-1", "codex-machine-2"]) - ); - expect(resolved.identities).toContain(resolved.personId); - }); - - it("carries no display name back rather than an empty one when none was set", () => { - const noDisplayName: PersonIdentity = { - personId: "person-a", - origin: "minted", - alsoMe: ["claude-machine-1"], - }; - - const resolved = resolvePerson(noDisplayName, "claude-machine-1"); - - expect(resolved.resolution).toBe("mapped"); - expect(resolved).not.toHaveProperty("displayName"); - }); - - it("a display name is carried back untouched, never derived", () => { - const resolved = resolvePerson(identityWithAlsoMe(), "claude-machine-1"); - - expect(resolved.displayName).toBe("Ada"); - }); - - it("an identity can be built with no display name and no added identifiers, reading back with neither invented", () => { - const bare: PersonIdentity = { personId: "person-a", origin: "minted", alsoMe: [] }; - - expect(bare).not.toHaveProperty("displayName"); - expect(bare.alsoMe).toEqual([]); - }); - - // The proof belongs to the compiler: `PersonIdentity` has no field for a second person's - // claim, and the day a roster field returns the directive below has nothing to suppress. - it("admits no second person's claim, at compile time", () => { - const identity: PersonIdentity = { - personId: "person-a", - origin: "adopted", - alsoMe: [], - // @ts-expect-error PersonIdentity carries one person, never a roster of entries - entries: [{ personId: "person-b", identities: ["person-b"] }], - }; - - expect(identity).toBeDefined(); - }); - - // Add an identifier, then adopt it: before the invariant moved into the writers, `also_me` - // kept the newly canonical identifier and one row named it twice as its own evidence. - it("adopting an identifier already added onto this person does not list it as added onto them", () => { - const linked = withAlsoMeAdded( - { personId: "machine-a", origin: "minted", alsoMe: [] }, - "machine-b" - ); - - const adopted = withPersonIdAdopted(linked, "machine-b"); - - expect(adopted.personId).toBe("machine-b"); - expect(adopted.alsoMe).toEqual([]); - expect(resolvePerson(adopted, "machine-b").identities).toEqual(["machine-b"]); - }); - - it("refuses to add a person's own identifier onto themselves", () => { - const identity = { personId: "me", origin: "minted" as const, alsoMe: [] }; - - expect(withAlsoMeAdded(identity, "me").alsoMe).toEqual([]); - }); - - it("adopting keeps every other added identifier, and the display name", () => { - const current = { - personId: "machine-a", - origin: "minted" as const, - alsoMe: ["machine-b", "machine-c"], - displayName: "carried, never produced", - }; - - const adopted = withPersonIdAdopted(current, "machine-b"); - - expect(adopted.alsoMe).toEqual(["machine-c"]); - expect(adopted.displayName).toBe("carried, never produced"); - expect(adopted.origin).toBe("adopted"); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts b/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts deleted file mode 100644 index 306161d76..000000000 --- a/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - DEFAULT_REPORT_DAYS, - resolveReportPeriod, -} from "../../../../src/contexts/telemetry/domain/report-period.js"; -import { InvalidReportDayError, InvalidReportSpanError } from "../../../../src/kernel/errors.js"; - -const TODAY = new Date("2026-08-21T09:30:00Z"); - -describe("resolveReportPeriod", () => { - it("uses two absolute days as given", () => { - expect(resolveReportPeriod({ from: "2026-07-01", to: "2026-07-31" }, TODAY)).toEqual({ - fromDay: "2026-07-01", - toDay: "2026-07-31", - }); - }); - - it("resolves a number of days back into two absolute days, ending today", () => { - expect(resolveReportPeriod({ days: "3" }, TODAY)).toEqual({ - fromDay: "2026-08-19", - toDay: "2026-08-21", - }); - }); - - it("counts the span inclusively, so one day is today alone", () => { - expect(resolveReportPeriod({ days: "1" }, TODAY)).toEqual({ - fromDay: "2026-08-21", - toDay: "2026-08-21", - }); - }); - - it("uses the documented default when nothing is said", () => { - expect(resolveReportPeriod({}, TODAY)).toEqual( - resolveReportPeriod({ days: String(DEFAULT_REPORT_DAYS) }, TODAY) - ); - }); - - it("counts a span back from --to, not from today", () => { - expect(resolveReportPeriod({ to: "2026-07-31", days: "2" }, TODAY)).toEqual({ - fromDay: "2026-07-30", - toDay: "2026-07-31", - }); - }); - - it("ends today when only a start is given", () => { - expect(resolveReportPeriod({ from: "2026-08-01" }, TODAY)).toEqual({ - fromDay: "2026-08-01", - toDay: "2026-08-21", - }); - }); - - it("reads a period given end-first as the same period", () => { - expect(resolveReportPeriod({ from: "2026-07-31", to: "2026-07-01" }, TODAY)).toEqual( - resolveReportPeriod({ from: "2026-07-01", to: "2026-07-31" }, TODAY) - ); - }); - - it("crosses a month boundary by the calendar, not by arithmetic on the day number", () => { - expect(resolveReportPeriod({ to: "2026-03-02", days: "3" }, TODAY).fromDay).toBe("2026-02-28"); - }); - - it("refuses a day that is not one, naming the flag it came from", () => { - for (const [flag, value] of [ - ["--from", "notaday"], - ["--from", "2026-8-1"], - ["--to", "2026-02-31"], - ["--to", "yesterday"], - ["--from", ""], - ] as const) { - expect( - () => resolveReportPeriod({ [flag === "--from" ? "from" : "to"]: value }, TODAY), - `${flag} ${value}` - ).toThrow(InvalidReportDayError); - } - }); - - it("names the flag and what it expected, rather than throwing out of a date routine", () => { - expect(() => resolveReportPeriod({ from: "notaday" }, TODAY)).toThrow(/--from.*YYYY-MM-DD/u); - }); - - it("refuses a well-shaped day no calendar has as an invalid day, never as a date routine's own error", () => { - expect(() => resolveReportPeriod({ from: "2026-13-01" }, TODAY)).toThrow(InvalidReportDayError); - }); - - it("names --to when the end is not a day", () => { - expect(() => resolveReportPeriod({ to: "notaday" }, TODAY)).toThrow(/--to.*YYYY-MM-DD/u); - }); - - it("accepts the longest span, ten years, and counts it back from the end", () => { - expect(resolveReportPeriod({ to: "2026-08-21", days: "3650" }, TODAY)).toEqual({ - fromDay: "2016-08-24", - toDay: "2026-08-21", - }); - }); - - it("refuses a span that is not a whole number of days", () => { - for (const value of ["0", "-1", "1.5", "many", "4000"]) { - expect(() => resolveReportPeriod({ days: value }, TODAY), value).toThrow( - InvalidReportSpanError - ); - } - }); - - it("resolves the same request the same way twice", () => { - expect(resolveReportPeriod({ days: "7" }, TODAY)).toEqual( - resolveReportPeriod({ days: "7" }, TODAY) - ); - }); - - it("reads no clock of its own", () => { - const source = readFileSync( - fileURLToPath( - new URL("../../../../src/contexts/telemetry/domain/report-period.ts", import.meta.url) - ), - "utf8" - ); - - expect(source).not.toContain("Date.now"); - expect(source).not.toContain("new Date()"); - // A different "today" gives a different answer, which is what makes it the caller's. - expect(resolveReportPeriod({ days: "1" }, new Date("2026-01-01T00:00:00Z")).toDay).toBe( - "2026-01-01" - ); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts deleted file mode 100644 index 5e73658ca..000000000 --- a/cli/tests/contexts/telemetry/domain/report/axes/day-rows.unit.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { dayRange } from "../../../../../../src/contexts/telemetry/domain/report/axes/day-rows.js"; - -describe("dayRange", () => { - it("lists each UTC day from the first to the last exactly once", () => { - expect(dayRange("2026-08-30", "2026-09-02")).toStrictEqual([ - "2026-08-30", - "2026-08-31", - "2026-09-01", - "2026-09-02", - ]); - }); - - it("lists a one-day period as that one day", () => { - expect(dayRange("2026-08-18", "2026-08-18")).toStrictEqual(["2026-08-18"]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts deleted file mode 100644 index ded85ac1f..000000000 --- a/cli/tests/contexts/telemetry/domain/report/axes/flow-rows.unit.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type CostReportSessionJournal, - TotalsAccumulator, -} from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; -import type { FlowInterval } from "../../../../../../src/contexts/telemetry/domain/flow-attribution.js"; -import { - allFlowIntervalsByVendorId, - type FlowRowKey, - flowKeyOf, - flowRows, -} from "../../../../../../src/contexts/telemetry/domain/report/axes/flow-rows.js"; -import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -function record(overrides: Partial = {}): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - ...overrides, - }; -} - -function totalsOf(costUsd: number): TotalsAccumulator { - const accumulator = new TotalsAccumulator(); - accumulator.add(record({ cost_usd: costUsd })); - return accumulator; -} - -function sdlcRunAt(startedAt: string): FlowInterval { - const startMs = Date.parse(startedAt); - return { - skill: "aidd-orchestrator:01-sdlc", - startMs, - endMs: startMs + 60_000, - closedBy: "journal-end", - }; -} - -describe("allFlowIntervalsByVendorId", () => { - it("gives a session that opened no flow no entry at all", () => { - const journal: CostReportSessionJournal = { - vendorId: "s-1", - tool: "claude", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [], - }; - - expect(allFlowIntervalsByVendorId([journal])).toStrictEqual(new Map()); - }); -}); - -describe("flowKeyOf, for a record no interval covers", () => { - const outside = flowKeyOf(record(), new Map()); - - it("keys a tool-stated step that orchestrates nothing on the outside-every-flow row", () => { - const key = flowKeyOf( - record({ step_attribution: "tool-stated", step: "aidd-dev:01-plan" }), - new Map() - ); - - expect(typeof key).toBe("symbol"); - expect(key).toBe(outside); - }); - - it("keys a tool-stated orchestrating step on the skill's own name", () => { - const key = flowKeyOf( - record({ step_attribution: "tool-stated", step: "aidd-orchestrator:01-sdlc" }), - new Map() - ); - - expect(key).toBe("aidd-orchestrator:01-sdlc"); - }); -}); - -describe("flowRows", () => { - it("breaks a tie between two runs of one skill on the moment each started", () => { - const flows = new Map([ - [sdlcRunAt("2026-08-18T11:00:00Z"), totalsOf(1)], - [sdlcRunAt("2026-08-18T10:00:00Z"), totalsOf(1)], - ]); - - expect(flowRows(flows).map((row) => row.startedAt)).toStrictEqual([ - "2026-08-18T10:00:00Z", - "2026-08-18T11:00:00Z", - ]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts deleted file mode 100644 index 51cba9c74..000000000 --- a/cli/tests/contexts/telemetry/domain/report/axes/person-rows.unit.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; -import type { ResolvedPerson } from "../../../../../../src/contexts/telemetry/domain/person-resolution.js"; -import { - type PersonGroup, - personGroupKey, - personRawIdOf, - personRows, -} from "../../../../../../src/contexts/telemetry/domain/report/axes/person-rows.js"; -import { - parseTelemetrySinkLine, - type TelemetrySinkRecord, -} from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -const NONE: ResolvedPerson = { resolution: "none", identities: [] }; - -function group(resolved: ResolvedPerson, costUsd: number): PersonGroup { - const totals = new TotalsAccumulator(); - totals.add({ ...BASE, cost_usd: costUsd }); - return { resolved, totals }; -} - -function rowsOf(...groups: readonly PersonGroup[]) { - return personRows(new Map(groups.map((entry, index) => [`k${index}`, entry]))); -} - -describe("personRawIdOf", () => { - it("reads an empty person_id as no identifier", () => { - expect(personRawIdOf({ ...BASE, person_id: "" })).toBeUndefined(); - }); - - it("reads a person_id stored as a number as no identifier", () => { - const stored = parseTelemetrySinkLine(JSON.stringify({ ...BASE, person_id: 42 })); - - expect(personRawIdOf(stored)).toBeUndefined(); - }); - - it("reads a real identifier as itself", () => { - expect(personRawIdOf({ ...BASE, person_id: "claude-machine-1" })).toBe("claude-machine-1"); - }); -}); - -describe("personGroupKey", () => { - it("keys an unresolved person on its raw identifier", () => { - expect(personGroupKey({ resolution: "unresolved", identities: ["raw-1"] })).toBe("raw-1"); - }); - - it("keys this machine's own person on the shared no-identifier key, never its personId", () => { - const key = personGroupKey({ resolution: "this-machine", personId: "p", identities: ["p"] }); - - expect(typeof key).toBe("symbol"); - expect(key).toBe(personGroupKey(NONE)); - }); - - it("keys a mapped person carrying no personId on the shared no-identifier key", () => { - const key = personGroupKey({ resolution: "mapped", identities: ["raw-1"] }); - - expect(typeof key).toBe("symbol"); - expect(key).toBe(personGroupKey(NONE)); - }); - - it("keys an unresolved person with no identifier on the shared no-identifier key", () => { - const key = personGroupKey({ resolution: "unresolved", identities: [] }); - - expect(typeof key).toBe("symbol"); - expect(key).toBe(personGroupKey(NONE)); - }); -}); - -describe("personRows", () => { - it("carries neither person nor displayName on a row that resolved to nobody", () => { - expect(rowsOf(group(NONE, 1))).toStrictEqual([ - { resolution: "none", identities: [], totals: { requests: 1, costMicroUsd: 1_000_000 } }, - ]); - }); - - it("reads mapped, this-machine, unresolved, none, whatever their sizes", () => { - const rows = rowsOf( - group(NONE, 4), - group({ resolution: "unresolved", identities: ["raw"] }, 3), - group({ resolution: "this-machine", personId: "me", identities: ["me"] }, 2), - group({ resolution: "mapped", personId: "her", identities: ["her"] }, 1) - ); - - expect(rows.map((row) => row.resolution)).toStrictEqual([ - "mapped", - "this-machine", - "unresolved", - "none", - ]); - }); - - it("breaks a tie between two mapped people on the person, not the evidence", () => { - const rows = rowsOf( - group({ resolution: "mapped", personId: "b", identities: ["aaa"] }, 1), - group({ resolution: "mapped", personId: "a", identities: ["zzz"] }, 1) - ); - - expect(rows.map((row) => row.person)).toStrictEqual(["a", "b"]); - }); - - it("breaks a tie between two unresolved identifiers on the identifier", () => { - const rows = rowsOf( - group({ resolution: "unresolved", identities: ["raw-b"] }, 1), - group({ resolution: "unresolved", identities: ["raw-a"] }, 1) - ); - - expect(rows.map((row) => row.identities)).toStrictEqual([["raw-a"], ["raw-b"]]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts deleted file mode 100644 index b89a0ef93..000000000 --- a/cli/tests/contexts/telemetry/domain/report/axes/record-stated-rows.unit.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; -import { - type AgentKey, - agentRows, - modelKeyOf, - modelRows, - type PromptGroup, - projectRows, - promptRows, -} from "../../../../../../src/contexts/telemetry/domain/report/axes/record-stated-rows.js"; -import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function totalsOf(costUsd: number): TotalsAccumulator { - const accumulator = new TotalsAccumulator(); - accumulator.add({ ...BASE, cost_usd: costUsd }); - return accumulator; -} - -describe("modelKeyOf", () => { - it("keys a record naming no model on a symbol, never on undefined", () => { - expect(typeof modelKeyOf(BASE)).toBe("symbol"); - }); -}); - -describe("a tie between two rows of equal size", () => { - it("is broken on the project name", () => { - const rows = projectRows( - new Map([ - ["proj-b", totalsOf(1)], - ["proj-a", totalsOf(1)], - ]) - ); - - expect(rows.map((row) => row.project)).toStrictEqual(["proj-a", "proj-b"]); - }); - - it("is broken on the agent name", () => { - const rows = agentRows( - new Map([ - ["Plan", totalsOf(1)], - ["Explore", totalsOf(1)], - ]) - ); - - expect(rows.map((row) => row.agent)).toStrictEqual(["Explore", "Plan"]); - }); - - it("is broken on the prompt id", () => { - const rows = promptRows( - new Map([ - ["p-b", { totals: totalsOf(1) }], - ["p-a", { totals: totalsOf(1) }], - ]) - ); - - expect(rows.map((row) => row.prompt)).toStrictEqual(["p-a", "p-b"]); - }); - - it("is broken on the model name", () => { - const rows = modelRows( - new Map([ - ["claude-sonnet-5", totalsOf(1)], - ["claude-opus-5", totalsOf(1)], - ]) - ); - - expect(rows.map((row) => row.model)).toStrictEqual(["claude-opus-5", "claude-sonnet-5"]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts deleted file mode 100644 index 0cf67c738..000000000 --- a/cli/tests/contexts/telemetry/domain/report/axes/step-rows.unit.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { TotalsAccumulator } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; -import { - type StepGroup, - stepRowKey, - stepRows, -} from "../../../../../../src/contexts/telemetry/domain/report/axes/step-rows.js"; -import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function group(overrides: Partial): StepGroup { - const totals = new TotalsAccumulator(); - totals.add({ ...BASE, cost_usd: 1 }); - return { attribution: "unattributed", totals, ...overrides }; -} - -describe("stepRowKey", () => { - it("joins the attribution and the step with one space", () => { - expect(stepRowKey({ ...BASE, step_attribution: "tool-stated", step: "aidd-dev:01-plan" })).toBe( - "tool-stated aidd-dev:01-plan" - ); - }); - - it("leaves the step empty after the space when none was found", () => { - expect(stepRowKey(BASE)).toBe("unattributed "); - }); -}); - -describe("stepRows", () => { - it("carries no step key at all on the unattributed row", () => { - expect(stepRows(new Map([["unattributed ", group({})]]))).toStrictEqual([ - { attribution: "unattributed", totals: { requests: 1, costMicroUsd: 1_000_000 } }, - ]); - }); - - it("breaks a tie between two steps on the step name", () => { - const rows = stepRows( - new Map([ - ["b", group({ attribution: "tool-stated", step: "aidd-dev:02-implement" })], - ["a", group({ attribution: "tool-stated", step: "aidd-dev:01-plan" })], - ]) - ); - - expect(rows.map((row) => row.step)).toStrictEqual([ - "aidd-dev:01-plan", - "aidd-dev:02-implement", - ]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts deleted file mode 100644 index 3fb08ba97..000000000 --- a/cli/tests/contexts/telemetry/domain/report/axes/task-rows.unit.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type CostReportSessionJournal, - TotalsAccumulator, -} from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; -import { - backlogKeyOf, - backlogRows, - type TaskGroup, - taskRowKeyOf, - taskRowOf, - taskRows, -} from "../../../../../../src/contexts/telemetry/domain/report/axes/task-rows.js"; -import type { TaskBacklogDeclaration } from "../../../../../../src/contexts/telemetry/domain/task-backlog-link.js"; -import type { TelemetrySinkRecord } from "../../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const FROM_MS = Date.parse("2026-08-18T10:00:00Z"); -const TO_MS = Date.parse("2026-08-18T11:00:00Z"); -const TASK_PATH = "aidd_docs/tasks/2026_08/refactor-sink/plan.md"; -const TASK = "2026_08/refactor-sink"; - -function record(overrides: Partial = {}): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - event_timestamp: "2026-08-18T10:30:00Z", - ...overrides, - }; -} - -function journal(overrides: Partial = {}): CostReportSessionJournal { - return { - vendorId: "s-1", - tool: "claude", - writtenPaths: [TASK_PATH], - taskIntervals: [], - flowIntervals: [], - witnessed: { fromMs: FROM_MS, toMs: TO_MS }, - ...overrides, - }; -} - -function totalsOf(costUsd: number): TotalsAccumulator { - const accumulator = new TotalsAccumulator(); - accumulator.add(record({ cost_usd: costUsd })); - return accumulator; -} - -function rowOf(target: TelemetrySinkRecord, sessionJournal: CostReportSessionJournal | undefined) { - return taskRowOf( - target, - new Map([["s-1", sessionJournal?.taskIntervals ?? []]]), - new Map(sessionJournal === undefined ? [] : [["s-1", sessionJournal]]) - ); -} - -describe("taskRowKeyOf", () => { - it("joins the attribution and the task with one space", () => { - expect(taskRowKeyOf({ task: TASK, attribution: "declared" })).toBe(`declared ${TASK}`); - }); - - it("keeps a reason as its own key", () => { - expect(taskRowKeyOf("journal-silent")).toBe("journal-silent"); - }); -}); - -describe("taskRowOf, when the intervals were read but no journal reached the session", () => { - it("answers no-declaration rather than throwing", () => { - expect(rowOf(record(), undefined)).toBe("no-declaration"); - }); -}); - -describe("taskRowOf, inferring from a session's sole written task", () => { - it("infers at the first moment the journal witnessed", () => { - const row = rowOf(record({ event_timestamp: "2026-08-18T10:00:00Z" }), journal()); - - expect(row).toStrictEqual({ task: TASK, attribution: "inferred" }); - }); - - it("infers at the last moment the journal witnessed", () => { - const row = rowOf(record({ event_timestamp: "2026-08-18T11:00:00Z" }), journal()); - - expect(row).toStrictEqual({ task: TASK, attribution: "inferred" }); - }); - - it("infers nothing one second after the last witnessed moment", () => { - expect(rowOf(record({ event_timestamp: "2026-08-18T11:00:01Z" }), journal())).toBe( - "no-declaration" - ); - }); - - it("infers nothing one second before the first witnessed moment", () => { - expect(rowOf(record({ event_timestamp: "2026-08-18T09:59:59Z" }), journal())).toBe( - "precedes-journal" - ); - }); - - it("infers nothing from a journal that witnessed no readable moment", () => { - expect(rowOf(record(), journal({ witnessed: undefined }))).toBe("no-declaration"); - }); - - it("infers nothing for a record whose own moment does not parse", () => { - expect(rowOf(record({ event_timestamp: "not a moment" }), journal())).toBe("no-declaration"); - }); -}); - -describe("taskRows", () => { - function group(overrides: Partial): TaskGroup { - return { totals: totalsOf(1), ...overrides }; - } - - it("drops a named group missing its attribution", () => { - expect(taskRows(new Map([["k", group({ task: TASK })]]))).toStrictEqual([]); - }); - - it("drops a named group missing its task", () => { - expect(taskRows(new Map([["k", group({ attribution: "declared" })]]))).toStrictEqual([]); - }); - - it("breaks a tie between two tasks on the task name", () => { - const rows = taskRows( - new Map([ - ["b", group({ task: "2026_08/b", attribution: "declared" })], - ["a", group({ task: "2026_08/a", attribution: "declared" })], - ]) - ); - - expect(rows.map((row) => row.task)).toStrictEqual(["2026_08/a", "2026_08/b"]); - }); - - it("breaks a tie between one task's two attributions on the attribution", () => { - const rows = taskRows( - new Map([ - ["i", group({ task: TASK, attribution: "inferred" })], - ["d", group({ task: TASK, attribution: "declared" })], - ]) - ); - - expect(rows.map((row) => row.attribution)).toStrictEqual(["declared", "inferred"]); - }); -}); - -describe("backlogRows", () => { - const noneDeclared = backlogKeyOf({ task: TASK, attribution: "declared" }, undefined); - const unreadable = backlogKeyOf( - { task: TASK, attribution: "declared" }, - new Map([[TASK, { kind: "unreadable" }]]) - ); - - it("names a declared item as its own row, never as a reason", () => { - expect(backlogRows(new Map([["ISSUE-7", totalsOf(1)]]))).toStrictEqual([ - { backlog: "ISSUE-7", totals: { requests: 1, costMicroUsd: 1_000_000 } }, - ]); - }); - - it("keeps the no-declaration and unreadable rows after the named ones", () => { - const rows = backlogRows( - new Map([ - [noneDeclared, totalsOf(3)], - [unreadable, totalsOf(2)], - ["ISSUE-7", totalsOf(1)], - ]) - ); - - expect(rows).toStrictEqual([ - { backlog: "ISSUE-7", totals: { requests: 1, costMicroUsd: 1_000_000 } }, - { declaration: "none", totals: { requests: 1, costMicroUsd: 3_000_000 } }, - { declaration: "unreadable", totals: { requests: 1, costMicroUsd: 2_000_000 } }, - ]); - }); - - it("orders two named items largest first", () => { - const rows = backlogRows( - new Map([ - ["ISSUE-1", totalsOf(1)], - ["ISSUE-2", totalsOf(2)], - ]) - ); - - expect(rows.map((row) => row.backlog)).toStrictEqual(["ISSUE-2", "ISSUE-1"]); - }); - - it("breaks a tie between two named items on the item", () => { - const rows = backlogRows( - new Map([ - ["ISSUE-2", totalsOf(1)], - ["ISSUE-1", totalsOf(1)], - ]) - ); - - expect(rows.map((row) => row.backlog)).toStrictEqual(["ISSUE-1", "ISSUE-2"]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts deleted file mode 100644 index 556dd8593..000000000 --- a/cli/tests/contexts/telemetry/domain/report/axes/tool-rows.unit.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { CostReportToolDeclaration } from "../../../../../../src/contexts/telemetry/domain/cost-report.js"; -import { buildToolRows } from "../../../../../../src/contexts/telemetry/domain/report/axes/tool-rows.js"; - -const COVERED: CostReportToolDeclaration = { - tool: "claude", - coverage: "covered", - capability: { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, - }, -}; - -describe("buildToolRows", () => { - it("carries no reason key at all for a tool that declared none", () => { - expect(buildToolRows([COVERED], new Map(), new Map())).toStrictEqual([ - { - tool: "claude", - coverage: "covered", - capability: COVERED.capability, - totals: { requests: 0 }, - }, - ]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts deleted file mode 100644 index 450d3a5e7..000000000 --- a/cli/tests/contexts/telemetry/domain/report/record-reconciliation.unit.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - collapseBilledRequests, - collapseSupersededTurns, -} from "../../../../../src/contexts/telemetry/domain/report/record-reconciliation.js"; -import type { TelemetrySinkRecord } from "../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function record(overrides: Partial): TelemetrySinkRecord { - return { ...BASE, ...overrides }; -} - -describe("collapseSupersededTurns", () => { - it("keeps the reading of a re-read turn that carries the largest counters", () => { - const partial = record({ turn_id: "t-1", input_tokens: 10, output_tokens: 1 }); - const complete = record({ turn_id: "t-1", input_tokens: 10, output_tokens: 90 }); - - expect(collapseSupersededTurns([partial, complete])).toStrictEqual([complete]); - }); - - it("leaves a record read once exactly as it arrived", () => { - const only = record({ turn_id: "t-1", input_tokens: 10 }); - - expect(collapseSupersededTurns([only])).toStrictEqual([only]); - }); - - it("never groups an export record on its turn id, which there names a prompt several calls share", () => { - const first = record({ provenance: "export", turn_id: "p-1", output_tokens: 1 }); - const second = record({ provenance: "export", turn_id: "p-1", output_tokens: 2 }); - - expect(collapseSupersededTurns([first, second])).toStrictEqual([first, second]); - }); -}); - -describe("collapseBilledRequests", () => { - const BILLED = "req_1"; - - it("keeps the record carrying the amount as the one a report sums", () => { - const priced = record({ billed_request_id: BILLED, cost_usd: 0.5, output_tokens: 9 }); - const unpriced = record({ provenance: "export", billed_request_id: BILLED, output_tokens: 9 }); - - expect(collapseBilledRequests([unpriced, priced])).toStrictEqual([priced]); - }); - - it("answers the same record whichever of two unpriced readings came first", () => { - const local = record({ billed_request_id: BILLED, output_tokens: 9 }); - const exported = record({ provenance: "export", billed_request_id: BILLED, output_tokens: 9 }); - - expect(collapseBilledRequests([local, exported])).toStrictEqual( - collapseBilledRequests([exported, local]) - ); - expect(collapseBilledRequests([local, exported])).toHaveLength(1); - }); - - it("leaves a session record beside a request record sharing its billed id, never merged into it", () => { - const request = record({ billed_request_id: BILLED, cost_usd: 0.5 }); - const session = record({ kind: "session", billed_request_id: BILLED, output_tokens: 9 }); - - expect(collapseBilledRequests([request, session])).toStrictEqual([session, request]); - }); - - it("borrows the step a sibling resolved from a journal interval when the survivor states none", () => { - const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); - const stepped = record({ - billed_request_id: BILLED, - step_attribution: "journal-interval", - step: "implement", - step_plugin: "aidd-dev", - }); - - expect(collapseBilledRequests([priced, stepped])).toStrictEqual([ - { - ...priced, - step_attribution: "journal-interval", - step: "implement", - step_plugin: "aidd-dev", - }, - ]); - }); - - it("prefers the tool-stated step over a journal-interval one when both siblings resolved one", () => { - const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); - const fromJournal = record({ - billed_request_id: BILLED, - step_attribution: "journal-interval", - step: "plan", - }); - const fromTool = record({ - billed_request_id: BILLED, - step_attribution: "tool-stated", - step: "implement", - }); - - expect(collapseBilledRequests([priced, fromJournal, fromTool])).toStrictEqual([ - { ...priced, step_attribution: "tool-stated", step: "implement", step_plugin: undefined }, - ]); - }); - - it("keeps the step the survivor stated itself rather than a sibling's", () => { - const priced = record({ - provenance: "export", - billed_request_id: BILLED, - cost_usd: 0.5, - step_attribution: "journal-interval", - step: "plan", - }); - const fromTool = record({ - billed_request_id: BILLED, - step_attribution: "tool-stated", - step: "implement", - }); - - expect(collapseBilledRequests([priced, fromTool])).toStrictEqual([priced]); - }); - - it("borrows the person, name included, from the sibling that carried one", () => { - const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); - const known = record({ - billed_request_id: BILLED, - person_id: "ada", - person_display_name: "Ada L.", - }); - - expect(collapseBilledRequests([priced, known])).toStrictEqual([ - { ...priced, person_id: "ada", person_display_name: "Ada L." }, - ]); - }); - - it("borrows a person without inventing a display name the sibling never carried", () => { - const priced = record({ provenance: "export", billed_request_id: BILLED, cost_usd: 0.5 }); - const known = record({ billed_request_id: BILLED, person_id: "ada" }); - - expect(collapseBilledRequests([priced, known])).toStrictEqual([ - { ...priced, person_id: "ada" }, - ]); - }); - - it("keeps the person the survivor carried itself rather than a sibling's", () => { - const priced = record({ - billed_request_id: BILLED, - cost_usd: 0.5, - person_id: "ada", - person_display_name: "Ada L.", - }); - const other = record({ - provenance: "export", - billed_request_id: BILLED, - person_id: "bob", - person_display_name: "Bob", - }); - - expect(collapseBilledRequests([priced, other])).toStrictEqual([priced]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts deleted file mode 100644 index 336b75615..000000000 --- a/cli/tests/contexts/telemetry/domain/report/report-selection.unit.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - CostReportInput, - CostReportSessionJournal, -} from "../../../../../src/contexts/telemetry/domain/cost-report.js"; -import { - activeFilters, - emptySelectionOf, - selectionStages, - taskMembership, -} from "../../../../../src/contexts/telemetry/domain/report/report-selection.js"; -import type { TelemetrySinkRecord } from "../../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -const TASK = "2026_08/widgets"; -const OTHER_TASK = "2026_08/gadgets"; -const IN_TASK = { - startMs: Date.parse("2026-08-17T09:00:00Z"), - endMs: Date.parse("2026-08-17T10:00:00Z"), -}; - -function record(overrides: Partial): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - event_timestamp: "2026-08-17T09:30:00Z", - ...overrides, - }; -} - -function journal(overrides: Partial): CostReportSessionJournal { - return { - vendorId: "s-1", - tool: "claude", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [], - ...overrides, - }; -} - -function input(overrides: Partial): CostReportInput { - return { - fromDay: "2026-08-17", - toDay: "2026-08-17", - records: [], - journals: [], - declaredTools: [], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }; -} - -describe("taskMembership", () => { - it("keeps, per session, only the declared intervals naming the task asked for", () => { - const forTask = { ...IN_TASK, path: `aidd_docs/tasks/${TASK}/plan.md` }; - const forOther = { ...IN_TASK, path: `aidd_docs/tasks/${OTHER_TASK}/plan.md` }; - - const membership = taskMembership([journal({ taskIntervals: [forOther, forTask] })], TASK); - - expect([...membership.declaredIntervalsByVendorId]).toStrictEqual([["s-1", [forTask]]]); - }); - - it("gives a session that never declared the task no entry at all, rather than an empty one", () => { - const forOther = { ...IN_TASK, path: `aidd_docs/tasks/${OTHER_TASK}/plan.md` }; - - const membership = taskMembership([journal({ taskIntervals: [forOther] })], TASK); - - expect([...membership.declaredIntervalsByVendorId]).toStrictEqual([]); - }); -}); - -describe("selectionStages", () => { - it("names the task stage after the task, carrying what the task kept", () => { - const inside = record({ vendor_id: "s-1" }); - const outside = record({ vendor_id: "s-2" }); - const membership = taskMembership( - [journal({ writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], - TASK - ); - - const stages = selectionStages([inside, outside], input({ task: TASK }), membership); - - expect(stages).toStrictEqual([ - { name: undefined, value: undefined, records: [inside, outside] }, - { name: "task", value: TASK, records: [inside] }, - ]); - }); -}); - -describe("emptySelectionOf", () => { - it("answers nothing when every stage kept something", () => { - const kept = record({ model: "opus" }); - const stages = selectionStages([kept], input({ filters: { model: "opus" } }), null); - - expect(emptySelectionOf(stages, input({ filters: { model: "opus" } }), null)).toBeUndefined(); - }); - - it("knows a task that only a declared interval names", () => { - const declared = { ...IN_TASK, path: `aidd_docs/tasks/${TASK}/plan.md` }; - const membership = taskMembership([journal({ taskIntervals: [declared] })], TASK); - const outside = record({ event_timestamp: "2026-08-17T12:00:00Z" }); - const stages = selectionStages([outside], input({ task: TASK }), membership); - - expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ - filter: "task", - value: TASK, - known: true, - }); - }); - - it("knows a task that only a written file names", () => { - const membership = taskMembership( - [journal({ vendorId: "s-9", writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], - TASK - ); - const elsewhere = record({ vendor_id: "s-1" }); - const stages = selectionStages([elsewhere], input({ task: TASK }), membership); - - expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ - filter: "task", - value: TASK, - known: true, - }); - }); - - it("reports a task no session ever declared or wrote into as one never known", () => { - const membership = taskMembership([journal({})], TASK); - const stages = selectionStages([record({})], input({ task: TASK }), membership); - - expect(emptySelectionOf(stages, input({ task: TASK }), membership)).toStrictEqual({ - filter: "task", - value: TASK, - known: false, - }); - }); - - it("knows a tool from the declared list, however many tools are declared beside it", () => { - const declaredTools: CostReportInput["declaredTools"] = [ - { - tool: "claude", - coverage: "covered", - capability: { - localRead: null, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - { - tool: "codex", - coverage: "covered", - capability: { - localRead: null, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - ]; - const asked = input({ declaredTools, filters: { tool: "codex" } }); - const stages = selectionStages([record({ tool: "claude" })], asked, null); - - expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ - filter: "tool", - value: "codex", - known: true, - }); - }); - - it("reports a tool outside the declared list as one never known", () => { - const asked = input({ filters: { tool: "codex" } }); - const stages = selectionStages([record({ tool: "claude" })], asked, null); - - expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ - filter: "tool", - value: "codex", - known: false, - }); - }); - - it("knows a model seen anywhere the caller looked, even outside this period", () => { - const asked = input({ - filters: { model: "opus" }, - knownValues: { projects: new Set(), steps: new Set(), models: new Set(["opus"]) }, - }); - const stages = selectionStages([record({ model: "haiku" })], asked, null); - - expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ - filter: "model", - value: "opus", - known: true, - }); - }); - - it("blames the combination when the culprit's value matched the period before other filters ran", () => { - const asked = input({ - filters: { project: "acme", model: "opus" }, - knownValues: { projects: new Set(["acme"]), steps: new Set(), models: new Set(["opus"]) }, - }); - const acmeOnHaiku = record({ project_id: "acme", model: "haiku" }); - const otherOnOpus = record({ project_id: "other", model: "opus" }); - const stages = selectionStages([acmeOnHaiku, otherOnOpus], asked, null); - - expect(emptySelectionOf(stages, asked, null)).toStrictEqual({ - filter: "model", - value: "opus", - known: true, - combination: true, - }); - }); - - it("measures a combination against the task's own records, not the whole period, under --task", () => { - const membership = taskMembership( - [journal({ vendorId: "s-1", writtenPaths: [`aidd_docs/tasks/${TASK}/plan.md`] })], - TASK - ); - const asked = input({ - task: TASK, - filters: { model: "opus" }, - knownValues: { projects: new Set(), steps: new Set(), models: new Set(["opus"]) }, - }); - const inTaskOnHaiku = record({ vendor_id: "s-1", model: "haiku" }); - const outsideOnOpus = record({ vendor_id: "s-2", model: "opus" }); - const stages = selectionStages([inTaskOnHaiku, outsideOnOpus], asked, membership); - - expect(emptySelectionOf(stages, asked, membership)).toStrictEqual({ - filter: "model", - value: "opus", - known: true, - }); - }); - - it("never blames a combination on the task filter itself", () => { - const membership = taskMembership([journal({})], TASK); - const stages = selectionStages([record({})], input({ task: TASK }), membership); - - expect(emptySelectionOf(stages, input({ task: TASK }), membership)).not.toHaveProperty( - "combination" - ); - }); -}); - -describe("activeFilters", () => { - it("answers nothing for an empty filters object", () => { - expect(activeFilters({})).toBeUndefined(); - }); - - it("answers nothing when every filter is present but undefined", () => { - expect(activeFilters({ project: undefined, model: undefined })).toBeUndefined(); - }); - - it("keeps only the filters given, in the fixed order project, step, model, tool", () => { - expect(activeFilters({ tool: "claude", project: "acme" })).toStrictEqual({ - project: "acme", - tool: "claude", - }); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts deleted file mode 100644 index c512469d4..000000000 --- a/cli/tests/contexts/telemetry/domain/report/row-ordering.unit.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { CostTotals } from "../../../../../src/contexts/telemetry/domain/cost-report.js"; -import { - bySize, - isoSecondsFromMs, -} from "../../../../../src/contexts/telemetry/domain/report/row-ordering.js"; - -interface Row { - readonly key: string; - readonly totals: CostTotals; -} - -function ordered(rows: readonly Row[]): readonly string[] { - return bySize( - rows, - (row) => row.totals, - (row) => row.key - ).map((row) => row.key); -} - -describe("bySize", () => { - it("weighs a costless row by all four counters added together, the cache ones included", () => { - const rows: Row[] = [ - { key: "cache-heavy", totals: { requests: 1, cacheCreationTokens: 100 } }, - { key: "output-heavy", totals: { requests: 1, inputTokens: 5, outputTokens: 10 } }, - { key: "input-heavy", totals: { requests: 1, inputTokens: 10, outputTokens: 1 } }, - ]; - - expect(ordered(rows)).toEqual(["cache-heavy", "output-heavy", "input-heavy"]); - }); - - it("breaks a tie on the row's own key, so the same rows always come out in one order", () => { - const rows: Row[] = [ - { key: "b", totals: { requests: 1, inputTokens: 3 } }, - { key: "a", totals: { requests: 1, inputTokens: 3 } }, - ]; - - expect(ordered(rows)).toEqual(["a", "b"]); - expect(ordered([...rows].reverse())).toEqual(["a", "b"]); - }); - - it("leaves the rows it was given untouched", () => { - const rows: Row[] = [ - { key: "small", totals: { requests: 1, inputTokens: 1 } }, - { key: "large", totals: { requests: 1, inputTokens: 9 } }, - ]; - - bySize( - rows, - (row) => row.totals, - (row) => row.key - ); - - expect(rows.map((row) => row.key)).toEqual(["small", "large"]); - }); -}); - -describe("isoSecondsFromMs", () => { - it("renders a moment to the second, the way the journal spells a line's own moment", () => { - expect(isoSecondsFromMs(Date.parse("2026-08-17T09:04:05.000Z"))).toBe("2026-08-17T09:04:05Z"); - }); - - it("drops the milliseconds rather than rounding them into the next second", () => { - expect(isoSecondsFromMs(Date.parse("2026-08-17T09:04:05.999Z"))).toBe("2026-08-17T09:04:05Z"); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts b/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts deleted file mode 100644 index 2c20117ce..000000000 --- a/cli/tests/contexts/telemetry/domain/session-anchor.unit.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveSessionAnchor } from "../../../../src/contexts/telemetry/domain/session-anchor.js"; - -describe("resolveSessionAnchor", () => { - it("prefers the Codex thread over the enclosing Claude Code session", () => { - expect( - resolveSessionAnchor({ CODEX_THREAD_ID: "thread-1", CLAUDE_CODE_SESSION_ID: "claude-1" }) - ).toBe("thread-1"); - }); - - it("falls back to the Claude Code session when no Codex thread is set", () => { - expect(resolveSessionAnchor({ CLAUDE_CODE_SESSION_ID: "claude-1" })).toBe("claude-1"); - }); - - it("answers nothing when neither host named a session", () => { - expect(resolveSessionAnchor({})).toBeUndefined(); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts deleted file mode 100644 index 25cebeaa9..000000000 --- a/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - RunJournal, - RunJournalSessionStart, -} from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import { - anchorProjectElsewhere, - resolveSessionProject, - type SessionProject, -} from "../../../../src/contexts/telemetry/domain/session-project.js"; - -function sessionOf(overrides: Partial = {}): RunJournalSessionStart { - return { - type: "session_start", - at: "2026-08-20T09:59:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: "s-1", - ...overrides, - }; -} - -function journalOf(session?: RunJournalSessionStart): RunJournal { - return { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - ...(session ? { session } : {}), - }; -} - -describe("resolveSessionProject", () => { - it("prefers the remote, and says so", () => { - const journal = journalOf( - sessionOf({ project_id: "acme-widgets", project_remote: "git@github.com:acme/widgets.git" }) - ); - - expect(resolveSessionProject(journal)).toEqual({ - projectId: "git@github.com:acme/widgets.git", - projectField: "project_remote", - }); - }); - - it("falls back to the directory-name field when no remote exists", () => { - const journal = journalOf(sessionOf({ project_id: "acme-widgets" })); - - expect(resolveSessionProject(journal)).toEqual({ - projectId: "acme-widgets", - projectField: "project_id", - }); - }); - - it("names no project when the session carries neither field", () => { - expect(resolveSessionProject(journalOf(sessionOf()))).toBeNull(); - }); - - it("names no project for a journal with no session at all", () => { - expect(resolveSessionProject(journalOf())).toBeNull(); - }); - - it("names no project for a session with no journal at all", () => { - expect(resolveSessionProject(null)).toBeNull(); - }); - - it("reads an empty remote as none, falling back to the directory-name field", () => { - const journal = journalOf(sessionOf({ project_id: "acme-widgets", project_remote: "" })); - - expect(resolveSessionProject(journal)).toStrictEqual({ - projectId: "acme-widgets", - projectField: "project_id", - }); - }); - - it("names no project when both fields are empty strings", () => { - expect( - resolveSessionProject(journalOf(sessionOf({ project_id: "", project_remote: "" }))) - ).toBeNull(); - }); -}); - -describe("anchorProjectElsewhere", () => { - const HERE_REMOTE = "git@github.com:acme/widgets.git"; - const here: readonly SessionProject[] = [ - { projectId: HERE_REMOTE, projectField: "project_remote" }, - ]; - const BOTH: readonly SessionProject[] = [ - ...here, - { projectId: "widgets", projectField: "project_id" }, - ]; - - it("names the project a stored record puts the anchored session under", () => { - expect( - anchorProjectElsewhere(here, [ - { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, - ]) - ).toBe("git@github.com:acme/other.git"); - }); - - it("names nothing when a stored record puts the anchor under this very project", () => { - expect( - anchorProjectElsewhere(here, [ - { project_id: "git@github.com:acme/widgets.git", project_field: "project_remote" }, - ]) - ).toBeNull(); - }); - - it("compares nothing across two different namings", () => { - expect( - anchorProjectElsewhere(here, [{ project_id: "widgets", project_field: "project_id" }]) - ).toBeNull(); - }); - - it("reads one project named by both fields as two values, never one match", () => { - expect( - anchorProjectElsewhere( - [...here, { projectId: "acme-other", projectField: "project_id" }], - [{ project_id: HERE_REMOTE, project_field: "project_id" }] - ) - ).toBe(HERE_REMOTE); - }); - - it("names nothing for a record whose field is no field this journal ever wrote", () => { - expect( - anchorProjectElsewhere(BOTH, [ - { project_id: "git@github.com:acme/other.git", project_field: "project_slug" }, - ]) - ).toBeNull(); - }); - - it("names nothing for a record whose field names a project the record itself does not", () => { - expect(anchorProjectElsewhere(BOTH, [{ project_field: "project_id" }])).toBeNull(); - }); - - it("names nothing for a record naming a project by no field at all", () => { - expect(anchorProjectElsewhere(BOTH, [{ project_id: "acme-other" }])).toBeNull(); - }); - - it("reads an empty project on a record as naming none", () => { - expect( - anchorProjectElsewhere(BOTH, [{ project_id: "", project_field: "project_id" }]) - ).toBeNull(); - }); - - it("names a project the records put the anchor under by directory name", () => { - expect( - anchorProjectElsewhere(BOTH, [{ project_id: "acme-other", project_field: "project_id" }]) - ).toBe("acme-other"); - }); - - it("names nothing when the stored records say nothing about a project", () => { - expect(anchorProjectElsewhere(here, [{}, { project_id: "" }])).toBeNull(); - }); - - it("names nothing when no record carries the field that named the project", () => { - expect( - anchorProjectElsewhere(here, [{ project_id: "git@github.com:acme/other.git" }]) - ).toBeNull(); - }); - - it("names nothing when nothing here names a project to compare against", () => { - expect( - anchorProjectElsewhere( - [], - [{ project_id: "git@github.com:acme/other.git", project_field: "project_remote" }] - ) - ).toBeNull(); - }); - - it("names every project the records disagree on, rather than picking one", () => { - expect( - anchorProjectElsewhere(here, [ - { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, - { project_id: "git@github.com:acme/third.git", project_field: "project_remote" }, - { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, - ]) - ).toBe("git@github.com:acme/other.git, git@github.com:acme/third.git"); - }); - - it("names nothing when one of several records puts the anchor here", () => { - expect( - anchorProjectElsewhere(here, [ - { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, - { project_id: "git@github.com:acme/widgets.git", project_field: "project_remote" }, - ]) - ).toBeNull(); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts b/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts deleted file mode 100644 index bf9d4beaf..000000000 --- a/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { namesTheSameSkill } from "../../../../src/contexts/telemetry/domain/skill-name.js"; - -describe("namesTheSameSkill — one skill, two hosts, two spellings", () => { - it("holds for the identical spelling", () => { - expect(namesTheSameSkill("aidd-dev:01-plan", "aidd-dev:01-plan")).toBe(true); - expect(namesTheSameSkill("01-plan", "01-plan")).toBe(true); - }); - - it("holds when only one side carries the plugin, whichever side that is", () => { - // Cursor and Codex open a step as `01-plan`, while the end the skill echoes always says - // `aidd-dev:01-plan`. - expect(namesTheSameSkill("01-plan", "aidd-dev:01-plan")).toBe(true); - expect(namesTheSameSkill("aidd-dev:01-plan", "01-plan")).toBe(true); - }); - - it("never folds two qualified names together, however alike their skill halves", () => { - expect(namesTheSameSkill("aidd-dev:01-plan", "aidd-pm:01-plan")).toBe(false); - }); - - it("says no to two skills that share no name at all", () => { - expect(namesTheSameSkill("aidd-dev:01-plan", "aidd-dev:06-test")).toBe(false); - expect(namesTheSameSkill("01-plan", "06-test")).toBe(false); - }); - - it("drops the plugin at the first colon, the one shape this domain has", () => { - // A hyphenated skill name is still one name; only the `plugin:` prefix comes off. - expect(namesTheSameSkill("artifact-design", "aidd-context:artifact-design")).toBe(true); - expect(namesTheSameSkill("artifact-design", "aidd-context:artifact-diagramming")).toBe(false); - }); - - it("drops a one-letter plugin prefix, whose colon sits at index one", () => { - expect(namesTheSameSkill("01-plan", "p:01-plan")).toBe(true); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts deleted file mode 100644 index 2bc49c5af..000000000 --- a/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import { - attributeMoment, - buildStepIntervals, -} from "../../../../src/contexts/telemetry/domain/step-attribution.js"; - -function journalOf(...boundaries: RunJournal["boundaries"]): RunJournal { - return { boundaries, filesWritten: [], taskDeclarations: [] }; -} - -function journalWith( - boundaries: RunJournal["boundaries"], - filesWritten: RunJournal["filesWritten"] -): RunJournal { - return { boundaries, filesWritten, taskDeclarations: [] }; -} - -const A_START = { - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "aidd-dev:02-implement", -} as const; -const B_START = { - type: "step_start", - at: "2026-08-20T10:05:00Z", - skill: "aidd-dev:06-test", -} as const; -const A_AGAIN = { - type: "step_start", - at: "2026-08-20T10:10:00Z", - skill: "aidd-dev:02-implement", -} as const; -const TURN_END = { type: "turn_end", at: "2026-08-20T10:15:00Z" } as const; - -describe("step-attribution — pure: journal lines + records -> intervals", () => { - it("maps a moment inside a step interval to that step, marked as derived", () => { - const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); - - const attribution = attributeMoment(intervals, "2026-08-20T10:02:00Z"); - - expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:02-implement" }); - }); - - // A `turn_end` is a pause, not the end of a step - the same rule `buildTaskIntervals` and - // `buildFlowIntervals` already read from this very journal. - it("runs a step past a pause, to the journal's own last witnessed moment", () => { - const intervals = buildStepIntervals( - journalWith( - [A_START, TURN_END], - [{ type: "file_written", at: "2026-08-20T11:00:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:02-implement", - }); - expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T11:00:00Z")); - }); - - // No host emits when a skill's work finished (a `Skill` call's `tool_result` returns in a - // tenth of a second), so the skill declares its own end, and that end outranks any pause. - it("runs a step past every pause, to the end its own skill declared", () => { - const intervals = buildStepIntervals( - journalOf( - A_START, - TURN_END, - { type: "turn_end", at: "2026-08-20T10:20:00Z" }, - { type: "step_end", at: "2026-08-20T10:30:00Z", skill: "aidd-dev:02-implement" } - ) - ); - - const attribution = attributeMoment(intervals, "2026-08-20T10:25:00Z"); - - expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:02-implement" }); - }); - - // A skill invoking a second one leaves two open intervals; an end for the inner skill - // must leave the outer one running. - it("closes only the step its own skill names", () => { - const intervals = buildStepIntervals( - journalOf(A_START, B_START, { - type: "step_end", - at: "2026-08-20T10:07:00Z", - skill: "aidd-dev:06-test", - }) - ); - - const outer = intervals.find((interval) => interval.skill === "aidd-dev:02-implement"); - const inner = intervals.find((interval) => interval.skill === "aidd-dev:06-test"); - expect(inner?.endMs).toBe(Date.parse("2026-08-20T10:07:00Z")); - expect(outer?.endMs).toBe(Date.parse("2026-08-20T10:05:00Z")); - }); - - // Cursor and Codex name a skill by its folder alone, while the end a skill echoes always - // carries the plugin: compared exactly, a declared end closes nothing on those hosts. - it("closes a step opened by its bare name with the end its skill declares in full", () => { - const bareStart = { - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "02-implement", - } as const; - const intervals = buildStepIntervals( - journalOf( - bareStart, - { type: "turn_end", at: "2026-08-20T10:10:00Z" }, - { type: "step_end", at: "2026-08-20T10:30:00Z", skill: "aidd-dev:02-implement" } - ) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T10:30:00Z")); - }); - - // The journal carries a moment later than the disagreeing end deliberately: with that end - // as its last line, the assertion could not tell a refused closer from a cap. - it("still refuses an end whose plugin disagrees with the one that opened the step", () => { - const intervals = buildStepIntervals( - journalWith( - [A_START, { type: "step_end", at: "2026-08-20T10:02:00Z", skill: "aidd-pm:02-implement" }], - [{ type: "file_written", at: "2026-08-20T10:20:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T10:20:00Z")); - }); - - // Read as a boundary all the same, it would truncate whatever interval was running - a - // step it has no claim on. - it("ignores an end for a skill this session never started", () => { - const intervals = buildStepIntervals( - journalWith( - [A_START, { type: "step_end", at: "2026-08-20T10:02:00Z", skill: "some-other:skill" }], - [{ type: "file_written", at: "2026-08-20T10:20:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:03:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:02-implement", - }); - }); - - it("closes an interval at the next step_start, not at the turn's end past it", () => { - const intervals = buildStepIntervals(journalOf(A_START, B_START, TURN_END)); - - expect(attributeMoment(intervals, "2026-08-20T10:04:59Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:02-implement", - }); - expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:06-test", - }); - }); - - // A pause is not a closer, so what bounds the last step here is the journal's own last - // witnessed moment - which this journal's `turn_end` happens to be. - it("leaves nothing beyond the journal's last witnessed moment covered", () => { - const intervals = buildStepIntervals(journalOf(B_START, TURN_END)); - - expect(attributeMoment(intervals, "2026-08-20T10:14:59Z")).toMatchObject({ - source: "journal-interval", - }); - expect(attributeMoment(intervals, "2026-08-20T10:15:00Z")).toEqual({ - source: "unattributed", - }); - }); - - it("yields three intervals and two names from A, then B, then A", () => { - const intervals = buildStepIntervals(journalOf(A_START, B_START, A_AGAIN, TURN_END)); - - expect(intervals).toHaveLength(3); - expect(new Set(intervals.map((i) => i.skill))).toEqual( - new Set(["aidd-dev:02-implement", "aidd-dev:06-test"]) - ); - expect(attributeMoment(intervals, "2026-08-20T10:05:30Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:06-test", - }); - expect(attributeMoment(intervals, "2026-08-20T10:12:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:02-implement", - }); - }); - - it("reads a moment before the first boundary as unattributed, never folded into it", () => { - const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); - - const attribution = attributeMoment(intervals, "2026-08-20T09:59:59Z"); - - expect(attribution).toEqual({ source: "unattributed" }); - }); - - it("reads a record with no moment at all as unattributed, never the first interval", () => { - const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); - - expect(attributeMoment(intervals, undefined)).toEqual({ source: "unattributed" }); - }); - - it("does not let an unparseable boundary extend the step before it into the step after", () => { - const intervals = buildStepIntervals( - journalOf(A_START, { type: "turn_end", at: "not-a-date" }, B_START, TURN_END) - ); - - const attribution = attributeMoment(intervals, "2026-08-20T10:07:00Z"); - - expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:06-test" }); - }); - - it("reads every moment as unattributed when the journal opened no step", () => { - const intervals = buildStepIntervals(journalOf(TURN_END)); - - expect(attributeMoment(intervals, "2026-08-20T10:00:00Z")).toEqual({ - source: "unattributed", - }); - }); - - // `nowIso()` stamps at second resolution and the walk's sort is stable, so lines sharing - // a moment keep file order: a step whose end shares its start's moment covers nothing. - it("closes a step at an end sharing its own start's moment, covering nothing", () => { - const intervals = buildStepIntervals( - journalWith( - [ - A_START, - { type: "step_end", at: A_START.at, skill: A_START.skill }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" }, - ], - [{ type: "file_written", at: "2026-08-20T12:00:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse(A_START.at)); - expect(attributeMoment(intervals, A_START.at)).toEqual({ source: "unattributed" }); - }); - - // Reading the invoked skill's own `step_start` as the end of the orchestration credits an - // orchestration that ran for hours with the seconds before its first child. - it("does not let an invoked step close the orchestration that invoked it", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - const sdlc = intervals.find((interval) => interval.skill === "aidd-orchestrator:01-sdlc"); - expect(sdlc?.endMs).toBe(Date.parse("2026-08-20T11:00:00Z")); - }); - - // The invoked step is inside the orchestration, not beside it, so both intervals contain - // the same moment; the innermost answers, being the more specific claim. - it("attributes a moment inside both to the step, and one outside it to the orchestration", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, - { type: "step_end", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:02:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - expect(attributeMoment(intervals, "2026-08-20T10:07:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-pm:04-spec", - }); - // Past the invoked step's own declared end, back inside the orchestration alone. - expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - }); - - // An interval nothing closed ends at the journal's last witnessed moment, a bound and not - // a measurement, so where one sits inside another the enclosing one answers. - it("hands a moment to the orchestration when nothing ever closed the step inside it", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - }); - - // The first invoked step is closed by the second's own start, so its end is witnessed and - // it answers for what it covers; only the second is unclosed, and it is the one that yields. - it("keeps the earlier invoked step, and yields only the one nothing closed", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, - { type: "step_start", at: "2026-08-20T10:20:00Z", skill: "aidd-dev:01-plan" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:10:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-pm:04-spec", - }); - expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - }); - - // The yielding is between two intervals nothing closed, and no wider: here the - // orchestration states its own end, so the step inside runs past it and nothing encloses it. - it("keeps the innermost step when the orchestration around it states its own end", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, - { type: "step_end", at: "2026-08-20T10:20:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:15:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-pm:04-spec", - }); - }); - - // Nesting is declared, never inferred: only a skill `ORCHESTRATING_SKILLS` names invokes - // others. Two ordinary skills in a row are a sequence, and the second still ends the first. - it("still lets one ordinary step close another, which is a sequence and not a nesting", () => { - const intervals = buildStepIntervals(journalOf(A_START, B_START, TURN_END)); - - const first = intervals.find((interval) => interval.skill === A_START.skill); - expect(first?.endMs).toBe(Date.parse(B_START.at)); - }); - - // One orchestration does not nest inside another by default - the same rule - // `buildFlowIntervals` already applies to the wider concept. - it("lets one orchestration close another", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-orchestrator:02-backlog" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - const first = intervals.find((interval) => interval.skill === "aidd-orchestrator:01-sdlc"); - expect(first?.endMs).toBe(Date.parse("2026-08-20T10:05:00Z")); - }); - - it("touches no filesystem — the module imports none of Node's fs APIs", () => { - const url = new URL( - "../../../../src/contexts/telemetry/domain/step-attribution.ts", - import.meta.url - ); - const source = readFileSync(fileURLToPath(url), "utf8"); - - expect(source).not.toMatch(/from ["']node:fs/); - expect(source).not.toMatch(/require\(["']node:fs/); - }); -}); - -describe("buildStepIntervals — a step the session never closed", () => { - // An open interval is not the safer error: one captured session carries a single - // `vendor_id` spanning 22 days, so "everything afterward" is three weeks of foreign work. - it("caps a step nothing closed at the journal's own last witnessed moment", () => { - const intervals = buildStepIntervals( - journalWith( - [{ type: "step_start", at: "2026-08-17T10:00:00Z", skill: "aidd-dev:01-plan" }], - [{ type: "file_written", at: "2026-08-17T12:00:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(intervals).toEqual([ - { - skill: "aidd-dev:01-plan", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T12:00:00Z"), - closedBy: "journal-end", - }, - ]); - expect(attributeMoment(intervals, "2026-09-30T23:59:00Z")).toEqual({ source: "unattributed" }); - }); - - // The price of the cap: a journal whose only line is the opener has no later moment to cap - // at. `records-join` survives it - a record whose own tool named its step needs no interval. - it("covers nothing when the opener is the only moment the journal ever witnessed", () => { - const intervals = buildStepIntervals( - journalOf({ type: "step_start", at: "2026-08-17T10:00:00Z", skill: "aidd-dev:01-plan" }) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-17T10:00:00Z")); - expect(attributeMoment(intervals, "2026-08-17T10:00:01Z")).toEqual({ - source: "unattributed", - }); - }); -}); - -describe("buildStepIntervals — an orchestration starting while a plain step runs", () => { - it("closes the plain step at the orchestrating step_start, not at the journal's end", () => { - const intervals = buildStepIntervals( - journalWith( - [ - A_START, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-orchestrator:01-sdlc" }, - ], - [{ type: "file_written", at: "2026-08-20T11:00:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(intervals.find((interval) => interval.skill === A_START.skill)).toStrictEqual({ - skill: A_START.skill, - startMs: Date.parse(A_START.at), - endMs: Date.parse("2026-08-20T10:05:00Z"), - closedBy: "boundary", - }); - }); -}); - -describe("attributeMoment — two intervals opened at the same second", () => { - const SDLC_AT_TEN = { - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "aidd-orchestrator:01-sdlc", - } as const; - const SPEC_AT_TEN = { - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "aidd-pm:04-spec", - } as const; - const LATER_TURN_END = { type: "turn_end", at: "2026-08-20T11:00:00Z" } as const; - - it("answers the orchestration when it is the one that closes first", () => { - const intervals = buildStepIntervals( - journalOf( - SDLC_AT_TEN, - SPEC_AT_TEN, - { type: "step_end", at: "2026-08-20T10:20:00Z", skill: "aidd-orchestrator:01-sdlc" }, - LATER_TURN_END - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toStrictEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - }); - - it("answers the invoked step when it is the one that closes first", () => { - const intervals = buildStepIntervals( - journalOf( - SDLC_AT_TEN, - SPEC_AT_TEN, - { type: "step_end", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, - LATER_TURN_END - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toStrictEqual({ - source: "journal-interval", - step: "aidd-pm:04-spec", - }); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts deleted file mode 100644 index 7bd264c3b..000000000 --- a/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { momentFallsWithin } from "../../../../src/contexts/telemetry/domain/journal-intervals.js"; -import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import { - buildTaskIntervals, - taskUnattributedReason, -} from "../../../../src/contexts/telemetry/domain/task-attribution.js"; - -function journalOf( - taskDeclarations: RunJournal["taskDeclarations"], - boundaries: RunJournal["boundaries"] = [], - filesWritten: RunJournal["filesWritten"] = [] -): RunJournal { - return { boundaries, filesWritten, taskDeclarations }; -} - -const WANTED = { - type: "task_declared", - at: "2026-08-17T10:00:00Z", - path: "aidd_docs/tasks/2026_08/wanted/spec.md", -} as const; -const OTHER = { - type: "task_declared", - at: "2026-08-17T10:10:00Z", - path: "aidd_docs/tasks/2026_08/other/spec.md", -} as const; -const TURN_END = { type: "turn_end", at: "2026-08-17T10:15:00Z" } as const; - -describe("task-attribution — pure: journal lines -> bounded intervals", () => { - it("closes a declared interval at the turn_end that follows it", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); - - // A `turn_end` is a pause, not a change of subject. It stays a *witness*, so an interval - // with nothing after it still ends there; what changes is one with work after it. - it("keeps a declaration open across a turn_end, ending at the work that followed", () => { - const wrote = { - type: "file_written", - at: "2026-08-17T10:20:00Z", - path: "aidd_docs/tasks/2026_08/wanted/phase-1.md", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END], [wrote])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(wrote.at) }, - ]); - }); - - it("closes a declaration at a later declaration, never at the turn's own end past it", () => { - const intervals = buildTaskIntervals(journalOf([WANTED, OTHER], [TURN_END])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(OTHER.at) }, - { path: OTHER.path, startMs: Date.parse(OTHER.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); - - it("caps an unclosed declaration at its own moment, never at Infinity", () => { - // No turn_end at all - the session crashed right after declaring. - const intervals = buildTaskIntervals(journalOf([WANTED])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(WANTED.at) }, - ]); - expect(momentFallsWithin(intervals, "2026-08-17T10:30:00Z")).toBe(false); - }); - - it("caps an unclosed declaration at the last boundary the journal actually recorded", () => { - const laterStep = { - type: "step_start", - at: "2026-08-17T10:20:00Z", - skill: "aidd-dev:02-implement", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [laterStep])); - - // step_start is not one of the two kinds an interval closes on, but it is still the - // journal's own last recorded moment - the honest bound for a crash right after it. - expect(intervals[0].endMs).toBe(Date.parse(laterStep.at)); - }); - - it("never lets a step_start close a declared interval early - only task_declared and turn_end do", () => { - const stepBetween = { - type: "step_start", - at: "2026-08-17T10:05:00Z", - skill: "aidd-dev:02-implement", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [stepBetween, TURN_END])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); - - it("declares no interval at all for a journal that never named a task", () => { - expect(buildTaskIntervals(journalOf([], [TURN_END]))).toEqual([]); - }); - - it("drops a task_declared line whose own `at` this reader cannot parse, the same as one that was never written", () => { - // `taskUnattributedReason` folds this into "no-declaration" beside a session that truly - // never declared, which is why that label reads "no *usable* declaration". - const unparseable = { - type: "task_declared", - at: "not-a-real-timestamp", - path: WANTED.path, - } as const; - - expect(buildTaskIntervals(journalOf([unparseable], [TURN_END]))).toEqual([]); - }); - - it("emits no interval for a declared path this reader cannot turn into an identity, but still lets it close the interval before it", () => { - // A `..` segment passes `task-declared.cjs`'s looser gate and names no task; dropped from - // `closers` too, it would silently widen WANTED's interval past its own declaration. - const climbing = { - type: "task_declared", - at: "2026-08-17T10:10:00Z", - path: "aidd_docs/tasks/2026_08/../../etc/passwd", - } as const; - - const intervals = buildTaskIntervals(journalOf([WANTED, climbing], [TURN_END])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(climbing.at) }, - ]); - }); - - it("reads a moment inside the interval as covered, and one outside as not", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(momentFallsWithin(intervals, "2026-08-17T10:05:00Z")).toBe(true); - expect(momentFallsWithin(intervals, "2026-08-17T09:59:59Z")).toBe(false); - expect(momentFallsWithin(intervals, "2026-08-17T10:15:00Z")).toBe(false); - }); - - it("reads a record with no moment, or an unparseable one, as not covered", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(momentFallsWithin(intervals, undefined)).toBe(false); - expect(momentFallsWithin(intervals, "not-a-date")).toBe(false); - }); - - it("touches no filesystem — the module imports none of Node's fs APIs", () => { - const url = new URL( - "../../../../src/contexts/telemetry/domain/task-attribution.ts", - import.meta.url - ); - const source = readFileSync(fileURLToPath(url), "utf8"); - - expect(source).not.toMatch(/from ["']node:fs/); - expect(source).not.toMatch(/require\(["']node:fs/); - }); - - it("widens an unclosed declaration's end to a written file the journal witnessed after it", () => { - const writtenAfter = { - type: "file_written", - at: "2026-08-17T10:40:00Z", - path: "x.md", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [], [writtenAfter])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(writtenAfter.at) }, - ]); - // After the declaration, before the write, no turn_end anywhere in sight - the - // ordinary state of a session still running. - expect(momentFallsWithin(intervals, "2026-08-17T10:20:00Z")).toBe(true); - }); - - it("never lets a written file reach further back than the interval's own last closer", () => { - const writtenBefore = { - type: "file_written", - at: "2026-08-17T09:00:00Z", - path: "x.md", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END], [writtenBefore])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); - - it("still never runs away: a written file does not turn the interval open-ended", () => { - const writtenAfter = { - type: "file_written", - at: "2026-08-17T10:40:00Z", - path: "x.md", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [], [writtenAfter])); - - // Long after the last thing the journal witnessed. - expect(momentFallsWithin(intervals, "2026-08-20T00:00:00Z")).toBe(false); - }); - - it("clamps an unclosed interval's end to the report's own period end, never past it", () => { - // A clock-skewed `file_written` far in the future still parses, and no record this reader - // places can fall past the period's own end, so capping there costs nothing real. - const farFuture = { - type: "file_written", - at: "9999-12-31T00:00:00Z", - path: "x.md", - } as const; - const periodEndMs = Date.parse("2026-08-18T00:00:00Z"); - - const intervals = buildTaskIntervals(journalOf([WANTED], [], [farFuture]), periodEndMs); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: periodEndMs }, - ]); - expect(momentFallsWithin(intervals, "2040-01-01T00:00:00Z")).toBe(false); - }); - - it("leaves an unclosed interval's end exactly where a real closer put it, when that is well inside the period", () => { - // The clamp must never pull a legitimate end earlier - only a witnessed moment beyond - // the period end is capped. - const periodEndMs = Date.parse("2026-08-20T00:00:00Z"); - - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END]), periodEndMs); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); -}); - -describe("taskUnattributedReason — which of four distinct facts applies", () => { - it("names no-declaration for a session whose journal never declared a task", () => { - expect(taskUnattributedReason([], "2026-08-17T10:00:00Z")).toBe("no-declaration"); - }); - - it("names precedes-declaration for a record before the session's only declaration", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, "2026-08-17T09:00:00Z")).toBe("precedes-declaration"); - }); - - // A resumed transcript carries turns billed days before the session that read them ever - // started, so the sink dates them before its journal witnessed anything. - it("names precedes-journal for a record older than everything its journal witnessed", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); - - expect(taskUnattributedReason(intervals, "2026-08-10T12:00:00Z", journalFromMs)).toBe( - "precedes-journal" - ); - }); - - it("still names precedes-declaration inside the span, before the first declaration", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); - - expect(taskUnattributedReason(intervals, "2026-08-17T09:45:00Z", journalFromMs)).toBe( - "precedes-declaration" - ); - }); - - // The coverage check runs first: it is the fact that explains why no declaration could - // have covered this record at all. - it("names precedes-journal, not no-declaration, when the journal declared nothing either", () => { - const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); - - expect(taskUnattributedReason([], "2026-08-10T12:00:00Z", journalFromMs)).toBe( - "precedes-journal" - ); - }); - - it("never claims coverage for a journal that carries no readable moment", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, "2026-08-10T12:00:00Z", undefined)).toBe( - "precedes-declaration" - ); - }); - - it("names journal-silent for a record after the last declared interval's own end", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, "2026-08-17T11:00:00Z")).toBe("journal-silent"); - }); - - it("names journal-silent for a record with no moment, once a task was declared", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, undefined)).toBe("journal-silent"); - expect(taskUnattributedReason(intervals, "not-a-date")).toBe("journal-silent"); - }); - - it("reads a record at the journal's very first witnessed moment as inside the journal, not before it", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); - - expect(taskUnattributedReason(intervals, "2026-08-17T09:30:00Z", journalFromMs)).toBe( - "precedes-declaration" - ); - }); - - it("names precedes-declaration while any declaration still lies ahead of the moment", () => { - const intervals = buildTaskIntervals(journalOf([WANTED, OTHER], [TURN_END])); - - expect(taskUnattributedReason(intervals, "2026-08-17T10:05:00Z")).toBe("precedes-declaration"); - }); - - it("reads a record at the very instant of the only declaration as not before it", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, WANTED.at)).toBe("journal-silent"); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/task-backlog-link.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-backlog-link.unit.test.ts deleted file mode 100644 index 27b445a7b..000000000 --- a/cli/tests/contexts/telemetry/domain/task-backlog-link.unit.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { taskFolderPathFromIdentity } from "../../../../src/contexts/telemetry/domain/task-backlog-link.js"; - -describe("taskFolderPathFromIdentity — the folder a task's identity resolves to", () => { - it("resolves a forge-style identity to its own task folder", () => { - expect(taskFolderPathFromIdentity("2026_08/2026_08_21_cost-reporter")).toBe( - "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/" - ); - }); - - it("always ends with a trailing slash, so it composes with a bare file name", () => { - const path = taskFolderPathFromIdentity("2026_09/2026_09_01_the-upward-link"); - expect(path.endsWith("/")).toBe(true); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts deleted file mode 100644 index daac6affe..000000000 --- a/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - taskIdentitiesFromWrittenPaths, - taskIdentityFromWrittenPath, -} from "../../../../src/contexts/telemetry/domain/task-identity.js"; -import { journalFileWrites } from "../../../helpers/telemetry-journal-hook.js"; - -const FOLDER_TASK = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"; -const FILE_TASK = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter.md"; - -describe("taskIdentityFromWrittenPath", () => { - it("names the task a written path belongs to, by its month and its own name", () => { - expect(taskIdentityFromWrittenPath(FOLDER_TASK)).toBe("2026_08/2026_08_21_cost-reporter"); - }); - - it("resolves a folder task and a single-file task of the same name to one identity", () => { - // Both shapes are real tasks and one grows into the other; two identities would read as - // two tasks and split a single piece of work's cost in half. - expect(taskIdentityFromWrittenPath(FILE_TASK)).toBe(taskIdentityFromWrittenPath(FOLDER_TASK)); - }); - - it("reaches a file nested any depth inside the task folder", () => { - expect( - taskIdentityFromWrittenPath("aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/notes/a/b.md") - ).toBe("2026_08/2026_08_21_cost-reporter"); - }); - - it("names no task for a path outside any task folder", () => { - for (const path of [ - "cli/src/index.ts", - "aidd_docs/memory/architecture.md", - "aidd_docs/tasks/README.md", - "aidd_docs/tasks/not_a_month/2026_08_21_cost-reporter/plan.md", - "docs/aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md", - ]) { - expect(taskIdentityFromWrittenPath(path), path).toBeNull(); - } - }); - - it("names no task for the task folder itself, with nothing written inside it", () => { - // The journal records files, never directories; a bare folder path means nothing was - // written, and attributing on it would count a task that produced no work. - expect(taskIdentityFromWrittenPath("aidd_docs/tasks/2026_08/2026_08_21_cost-reporter")).toBe( - null - ); - }); - - it("names no task for a path that climbs out of the tree", () => { - expect(taskIdentityFromWrittenPath("aidd_docs/tasks/2026_08/../../../etc/passwd")).toBeNull(); - expect( - taskIdentityFromWrittenPath("aidd_docs/tasks/2026_08/2026_08_21_x/../../../secret.md") - ).toBeNull(); - }); - - it("resolves a task name that merely contains '..' as text, never mistaking it for a climb", () => { - // "2026_02_10_a..b" is a name, not a segment of exactly "..": it climbs nothing, and both - // `file-writes.cjs` and `task-declared.cjs` journal it. - expect(taskIdentityFromWrittenPath("aidd_docs/tasks/2026_02/2026_02_10_a..b/spec.md")).toBe( - "2026_02/2026_02_10_a..b" - ); - }); - - it("touches no filesystem — a string in, an identity or nothing out", () => { - const source = readFileSync( - fileURLToPath( - new URL("../../../../src/contexts/telemetry/domain/task-identity.ts", import.meta.url) - ), - "utf8" - ); - - expect(source).not.toContain("node:fs"); - expect(source).not.toContain("node:path"); - // A task nobody has ever created still resolves: this answers what a path says, not - // what exists. - expect(taskIdentityFromWrittenPath("aidd_docs/tasks/2099_12/2099_12_31_invented/x.md")).toBe( - "2099_12/2099_12_31_invented" - ); - }); -}); - -describe("taskIdentitiesFromWrittenPaths", () => { - it("names every task a session wrote into, once each, in first-seen order", () => { - expect( - taskIdentitiesFromWrittenPaths([ - "aidd_docs/tasks/2026_08/b-task/plan.md", - "aidd_docs/tasks/2026_08/a-task/spec.md", - "aidd_docs/tasks/2026_08/b-task/phase-1.md", - ]) - ).toEqual(["2026_08/b-task", "2026_08/a-task"]); - }); - - it("names no task for a session that wrote into none", () => { - expect(taskIdentitiesFromWrittenPaths(["cli/src/index.ts"])).toEqual([]); - expect(taskIdentitiesFromWrittenPaths([])).toEqual([]); - }); - - it("names a task for exactly the paths the hook journals, and for no others", () => { - // `hooks/` is copied verbatim by the build and can import nothing from `cli/`, so the - // writer's gate and this derivation are pinned to each other here. - const CANDIDATES = [ - "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md", - "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter.md", - "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/notes/a/b.md", - "aidd_docs/tasks/2026_02/2026_02_10_a..b/spec.md", - "aidd_docs/tasks/2026_08/plan.md", - "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter", - "aidd_docs/tasks/README.md", - "aidd_docs/memory/architecture.md", - "cli/src/index.ts", - ]; - - for (const candidate of CANDIDATES) { - const journalled = - journalFileWrites.taskFolderRelativePath("/repo", `/repo/${candidate}`) !== null; - - expect(taskIdentityFromWrittenPath(candidate) !== null, candidate).toBe(journalled); - } - }); -}); - -describe("taskIdentityFromWrittenPath — where a single-file task must start and end", () => { - it("names no task for a single file whose name only begins with .md", () => { - expect(taskIdentityFromWrittenPath("aidd_docs/tasks/2026_08/2026_08_21_x.md.bak")).toBeNull(); - }); - - it("names no task for a single-file task path nested under another directory", () => { - expect(taskIdentityFromWrittenPath("docs/aidd_docs/tasks/2026_08/2026_08_21_x.md")).toBeNull(); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts deleted file mode 100644 index 52bd5cc38..000000000 --- a/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - diagnoseTelemetryClaims, - type TelemetryClaim, - type TelemetryClaimId, - type TelemetryClaimJournal, - type TelemetryClaimToolRead, - type TelemetryEvidence, -} from "../../../../src/contexts/telemetry/domain/telemetry-claim.js"; - -const RUNS_DIR_LABEL = "aidd_docs/runs"; -const CODEX_CONFIG = "/home/.codex/config.toml"; - -function journal(overrides: Partial = {}): TelemetryClaimJournal { - return { vendorId: undefined, sessionStartAt: undefined, turnClosed: false, ...overrides }; -} - -function toolRead(overrides: Partial = {}): TelemetryClaimToolRead { - return { tool: "claude", sessionFound: false, hasIntervals: false, records: [], ...overrides }; -} - -function evidence(overrides: Partial = {}): TelemetryEvidence { - return { - journals: [], - toolReads: [], - runsDirLabel: RUNS_DIR_LABEL, - recorderDeclared: false, - recorderDeclarationReadable: true, - foreignSchemaVersions: [], - ...overrides, - }; -} - -function claimOf(overrides: Partial, id: TelemetryClaimId): TelemetryClaim { - const found = diagnoseTelemetryClaims(evidence(overrides)).find((c) => c.claim === id); - if (found === undefined) throw new Error(`no ${id} claim`); - return found; -} - -describe("the hook-fired claim, word for word", () => { - it("names the newest session_start across every anchored run file, whatever order they came in", () => { - const hookFired = claimOf( - { - journals: [ - journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" }), - journal({ vendorId: "s-2" }), - journal({ vendorId: "s-3", sessionStartAt: "2026-07-01T09:00:00Z" }), - ], - currentSessionId: "s-1", - }, - "hook-fired" - ); - - expect(hookFired).toStrictEqual({ - claim: "hook-fired", - verdict: "ok", - reason: "session-anchored", - detail: "3 run file(s), most recent session_start 2026-08-20T09:00:00Z", - }); - }); - - it("says the session_start was unreadable when no anchored run file carries one", () => { - const hookFired = claimOf( - { journals: [journal({ vendorId: "s-1" })], currentSessionId: "s-1" }, - "hook-fired" - ); - - expect(hookFired.detail).toBe( - "1 run file(s), most recent session_start an unreadable session_start" - ); - }); - - it("anchors this session on its own run file even when an older session's file sits beside it", () => { - const hookFired = claimOf( - { - journals: [ - journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" }), - journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" }), - ], - currentSessionId: "s-1", - }, - "hook-fired" - ); - - expect(hookFired.reason).toBe("session-anchored"); - }); - - it("spells out the Codex trust fault, its config path and both ways out", () => { - const hookFired = claimOf( - { - currentSessionId: "codex-1", - hookTrust: { readable: true, trusted: false, configPath: CODEX_CONFIG }, - }, - "hook-fired" - ); - - expect(hookFired).toStrictEqual({ - claim: "hook-fired", - verdict: "fail", - reason: "untrusted-codex-hook", - detail: - "Codex has not trusted this plugin's hook — no trusted_hash for hooks/hooks.json:session_start " + - "in /home/.codex/config.toml. Approve it interactively once, or pass " + - "--dangerously-bypass-hook-trust to codex exec for a headless run.", - }); - }); - - it("names this session as having left no run file once its Codex hook is trusted", () => { - const hookFired = claimOf( - { - journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], - currentSessionId: "codex-current", - hookTrust: { readable: true, trusted: true, configPath: CODEX_CONFIG }, - }, - "hook-fired" - ); - - expect(hookFired).toStrictEqual({ - claim: "hook-fired", - verdict: "fail", - reason: "session-left-no-run-file", - detail: "this session left no run file — the newest one is from 2026-07-01T09:00:00Z", - }); - }); - - it("adds nothing about trust to the never-fired fault when there is no trust gate", () => { - const hookFired = claimOf({ currentSessionId: "s-1" }, "hook-fired"); - - expect(hookFired.detail).toBe( - "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + - "recorder is declared nowhere this build checks" - ); - }); - - it("adds nothing about trust to the never-fired fault when the trust state is readable", () => { - const hookFired = claimOf( - { - currentSessionId: "codex-1", - hookTrust: { readable: true, trusted: true, configPath: CODEX_CONFIG }, - }, - "hook-fired" - ); - - expect(hookFired.detail).toBe( - "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + - "recorder is declared nowhere this build checks" - ); - }); - - it("appends the unreadable trust state, with its reason, to the never-fired fault", () => { - const hookFired = claimOf( - { - currentSessionId: "codex-1", - hookTrust: { readable: false, reason: "ENOENT" }, - }, - "hook-fired" - ); - - expect(hookFired.detail).toBe( - "no run file in aidd_docs/runs — the hook has never been observed firing, and the " + - "recorder is declared nowhere this build checks — Codex's own hook trust state could " + - "not be read either (ENOENT), so this may be the same cause" - ); - }); - - it("tells a damaged declaring file apart from one that never declared the recorder", () => { - const hookFired = claimOf( - { currentSessionId: "s-1", recorderDeclared: false, recorderDeclarationReadable: false }, - "hook-fired" - ); - - expect(hookFired).toStrictEqual({ - claim: "hook-fired", - verdict: "unknown", - reason: "recorder-declaration-unreadable", - detail: - "no run file in aidd_docs/runs yet, and whether the recorder is declared could not be " + - "read — see recorder declared, above, for which location. A damaged declaring file is " + - "not the same absence as one that never declared the recorder.", - }); - }); - - it("counts the anchorless run files and names the two causes, never a hook that did not fire", () => { - const hookFired = claimOf( - { currentSessionId: "s-1", journals: [journal(), journal()] }, - "hook-fired" - ); - - expect(hookFired).toStrictEqual({ - claim: "hook-fired", - verdict: "fail", - reason: "anchorless-run-file", - detail: - "2 run file(s) in aidd_docs/runs, but none carry a readable session_start to anchor " + - "them — a torn write, or a hooks block that registers another event without " + - "SessionStart, never a hook that has not fired", - }); - }); - - it("lists the foreign schema versions once each, ascending, beside the count of files", () => { - const hookFired = claimOf( - { currentSessionId: "s-1", foreignSchemaVersions: [3, 2, 3, 1] }, - "hook-fired" - ); - - expect(hookFired).toStrictEqual({ - claim: "hook-fired", - verdict: "fail", - reason: "journal-in-another-schema", - detail: - "4 run file(s) in aidd_docs/runs written under a schema this build does not read " + - "(1, 2, 3) — a journal from another version of the plugin, never a hook that did not fire", - }); - }); - - it("spells out whose project the anchored session is, word for word", () => { - const hookFired = claimOf( - { - journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-08-20T09:00:00Z" })], - currentSessionId: "s-elsewhere", - anchorInAnotherProject: "git@github.com:acme/other.git", - }, - "hook-fired" - ); - - expect(hookFired).toStrictEqual({ - claim: "hook-fired", - verdict: "unknown", - reason: "anchor-in-another-project", - detail: - "this session belongs to git@github.com:acme/other.git, not to this project — its " + - "stored figures name that project, and nothing here is evidence about its hook. The " + - "newest run file here is from 2026-08-20T09:00:00Z", - }); - }); - - it("spells out the missing anchor beside the newest session_start", () => { - const hookFired = claimOf( - { journals: [journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" })] }, - "hook-fired" - ); - - expect(hookFired).toStrictEqual({ - claim: "hook-fired", - verdict: "unknown", - reason: "no-session-anchor", - detail: - "1 run file(s), most recent session_start 2026-08-20T09:00:00Z — no session anchor " + - "available to tell whether this session's hook fired", - }); - }); -}); - -describe("the session-journalled claim, word for word", () => { - it("has nothing to read without a run file", () => { - expect(claimOf({}, "session-journalled")).toStrictEqual({ - claim: "session-journalled", - verdict: "unknown", - reason: "no-run-file-to-read", - detail: "no run file to read", - }); - }); - - it("counts the run files that carry only session_start", () => { - expect( - claimOf( - { journals: [journal({ vendorId: "s-1" }), journal({ vendorId: "s-2" })] }, - "session-journalled" - ) - ).toStrictEqual({ - claim: "session-journalled", - verdict: "fail", - reason: "only-session-start", - detail: "2 run file(s), all carrying only session_start — nothing closed the turn", - }); - }); - - it("counts the closed turns against the anchored run files", () => { - expect( - claimOf( - { - journals: [ - journal({ vendorId: "s-1", turnClosed: true }), - journal({ vendorId: "s-2" }), - journal({ vendorId: "s-3", turnClosed: true }), - ], - }, - "session-journalled" - ) - ).toStrictEqual({ - claim: "session-journalled", - verdict: "ok", - reason: "turn-closed", - detail: "2 of 3 run file(s) carry more than session_start", - }); - }); -}); - -describe("the tool-files-readable claim, word for word", () => { - it("has no session to look for when the journal names none", () => { - expect(claimOf({}, "tool-files-readable")).toStrictEqual({ - claim: "tool-files-readable", - verdict: "unknown", - reason: "no-session-named", - detail: "no session named by the journal", - }); - }); - - it("names every covered tool once and every journalled session when none was found", () => { - expect( - claimOf( - { - journals: [journal({ vendorId: "s-1" }), journal({ vendorId: "s-2" })], - toolReads: [ - toolRead({ tool: "claude" }), - toolRead({ tool: "codex" }), - toolRead({ tool: "claude" }), - ], - }, - "tool-files-readable" - ) - ).toStrictEqual({ - claim: "tool-files-readable", - verdict: "fail", - reason: "no-session-found-for-any-tool", - detail: - "no session found for any journalled session, across every covered tool (claude, codex) " + - "— while the journal names s-1, s-2", - }); - }); - - it("appends the failed attempts, quoting the last error, when none was found", () => { - expect( - claimOf( - { - journals: [journal({ vendorId: "s-1" })], - toolReads: [ - toolRead({ tool: "claude", error: "EACCES" }), - toolRead({ tool: "codex", error: "ENOENT" }), - ], - }, - "tool-files-readable" - ).detail - ).toBe( - "no session found for any journalled session, across every covered tool (claude, codex) " + - "— while the journal names s-1 — 2 read attempt(s) failed: ENOENT" - ); - }); - - it("tallies each tool's reads, joined by a semicolon, with no failure suffix when none failed", () => { - expect( - claimOf( - { - journals: [journal({ vendorId: "s-1" })], - toolReads: [ - toolRead({ tool: "claude", sessionFound: true }), - toolRead({ tool: "claude", sessionFound: false }), - toolRead({ tool: "codex", sessionFound: false }), - ], - }, - "tool-files-readable" - ) - ).toStrictEqual({ - claim: "tool-files-readable", - verdict: "ok", - reason: "session-found", - detail: "claude: 1 of 2 session(s) read; codex: 0 of 1 session(s) read", - }); - }); - - it("counts the reads a tool could not make beside the ones it made", () => { - expect( - claimOf( - { - journals: [journal({ vendorId: "s-1" })], - toolReads: [ - toolRead({ tool: "claude", sessionFound: true }), - toolRead({ tool: "claude", error: "EACCES" }), - toolRead({ tool: "claude", error: "ENOENT" }), - ], - }, - "tool-files-readable" - ).detail - ).toBe("claude: 1 of 3 session(s) read, 2 could not be read"); - }); -}); - -describe("the records-join claim, word for word", () => { - it("has nothing to join without a record", () => { - expect(claimOf({ toolReads: [toolRead()] }, "records-join")).toStrictEqual({ - claim: "records-join", - verdict: "unknown", - reason: "no-record-to-join", - detail: "no record read to join", - }); - }); - - it("has no join material without an interval or a tool-stated step", () => { - expect( - claimOf( - { toolReads: [toolRead({ records: [{ stepAttribution: "unattributed" }] })] }, - "records-join" - ) - ).toStrictEqual({ - claim: "records-join", - verdict: "unknown", - reason: "no-join-material", - detail: "no step interval and no tool-stated step — see session journalled", - }); - }); - - it("takes one read's intervals as join material for every read's records", () => { - expect( - claimOf( - { - toolReads: [ - toolRead({ tool: "claude", hasIntervals: true }), - toolRead({ tool: "codex", records: [{ stepAttribution: "unattributed" }] }), - ], - }, - "records-join" - ) - ).toStrictEqual({ - claim: "records-join", - verdict: "fail", - reason: "all-unattributed", - detail: "1 record(s) found, joined: 0 — every record unattributed", - }); - }); - - it("takes a single tool-stated record as join material, with no interval at all", () => { - expect( - claimOf( - { - toolReads: [ - toolRead({ - records: [ - { stepAttribution: "unattributed" }, - { stepAttribution: "tool-stated" }, - { stepAttribution: "unattributed" }, - ], - }), - ], - }, - "records-join" - ) - ).toStrictEqual({ - claim: "records-join", - verdict: "ok", - reason: "records-joined", - detail: "1 of 3 record(s) joined a step, 2 unattributed", - }); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts deleted file mode 100644 index 8f3c48dbb..000000000 --- a/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts +++ /dev/null @@ -1,617 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { - diagnoseTelemetryClaims, - type NoRunFileReason, - type TelemetryClaim, - type TelemetryClaimId, - type TelemetryClaimJournal, - type TelemetryClaimToolRead, - type TelemetryClaimVerdict, - type TelemetryEvidence, -} from "../../../../src/contexts/telemetry/domain/telemetry-claim.js"; -import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; - -const RUNS_DIR_LABEL = "aidd_docs/runs"; - -function journal(overrides: Partial = {}): TelemetryClaimJournal { - return { vendorId: undefined, sessionStartAt: undefined, turnClosed: false, ...overrides }; -} - -function toolRead(overrides: Partial = {}): TelemetryClaimToolRead { - return { tool: "claude", sessionFound: false, hasIntervals: false, records: [], ...overrides }; -} - -function evidence(overrides: Partial = {}): TelemetryEvidence { - return { - journals: [], - toolReads: [], - runsDirLabel: RUNS_DIR_LABEL, - // Nothing declares the recorder by default, so an empty journal reads as the failure it - // already did. The declared branch sets this explicitly. - recorderDeclared: false, - // Readable by default; the "could not be read" branch sets this to `false` explicitly. - recorderDeclarationReadable: true, - // Empty by default: every journal on disk states the schema this build reads, or none. - foreignSchemaVersions: [], - ...overrides, - }; -} - -function claim( - result: readonly TelemetryClaim[], - id: TelemetryClaimId -): TelemetryClaim | undefined { - return result.find((c) => c.claim === id); -} - -describe("diagnoseTelemetryClaims — hook fired", () => { - it("reads ok once the current session's own run file is found among them", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" })], - currentSessionId: "s-1", - }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("ok"); - expect(hookFired?.reason).toBe("session-anchored"); - expect(hookFired?.detail).toMatch(/1 run file\(s\)/u); - }); - - it("names the hook never having fired when no run file appears", () => { - const result = diagnoseTelemetryClaims(evidence({ currentSessionId: "s-1" })); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("fail"); - expect(hookFired?.reason).toBe("recorder-declared-nowhere"); - expect(hookFired?.detail).toMatch(/never been observed firing/u); - }); - - it("names an unrecognised payload as its own fault, distinct from never firing", () => { - const result = diagnoseTelemetryClaims( - evidence({ currentSessionId: "s-1", unrecognisedPayloadAt: "2026-08-22T09:00:00Z" }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("fail"); - expect(hookFired?.reason).toBe("unrecognised-payload"); - expect(hookFired?.detail).toContain("2026-08-22T09:00:00Z"); - }); - - it("does not read a torn run file (session-less) as an unrecognised payload without the marker", () => { - const result = diagnoseTelemetryClaims( - evidence({ journals: [journal({ vendorId: undefined })], currentSessionId: "s-1" }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.reason).toBe("anchorless-run-file"); - expect(hookFired?.detail).not.toMatch(/matched no known host/u); - }); - - it("names an anchorless run file as its own reason, unconditional on the recorder's own declaration", () => { - const declared = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: undefined })], - currentSessionId: "s-1", - recorderDeclared: true, - }) - ); - const hookFired = claim(declared, "hook-fired"); - // A run file existing at all is direct evidence the recorder ran, so this is a failure and - // not "no run file yet, nothing to evaluate". - expect(hookFired?.verdict).toBe("fail"); - expect(hookFired?.reason).toBe("anchorless-run-file"); - expect(hookFired?.detail).not.toMatch(/nothing to evaluate/u); - expect(hookFired?.detail).not.toMatch(/declared nowhere/u); - }); - - // A refused journal leaves `journals` empty, which every other branch reads as "no run - // file"; the version disagreement is the fact that is actually known. - it("names a journal written under another schema, never a torn write or a missing file", () => { - const result = diagnoseTelemetryClaims( - evidence({ currentSessionId: "s-1", foreignSchemaVersions: [3] }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("fail"); - expect(hookFired?.reason).toBe("journal-in-another-schema"); - expect(hookFired?.detail).toContain("3"); - expect(hookFired?.detail).not.toMatch(/declared nowhere/u); - expect(hookFired?.detail).not.toMatch(/none carry a readable session_start/u); - }); - - // A build that cannot read a journal's schema cannot tell whether its session_start is - // missing or merely shaped differently, so blaming a torn write asserts what it cannot see. - it("prefers the schema disagreement over an anchorless file when both are present", () => { - const result = diagnoseTelemetryClaims( - evidence({ - currentSessionId: "s-1", - journals: [journal({ vendorId: undefined })], - foreignSchemaVersions: [3], - }) - ); - - expect(claim(result, "hook-fired")?.reason).toBe("journal-in-another-schema"); - }); - - it("names this session as having left no run file when an older one exists but not its own", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], - currentSessionId: "s-current", - }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("fail"); - expect(hookFired?.reason).toBe("session-left-no-run-file"); - expect(hookFired?.detail).toContain("2026-07-01T09:00:00Z"); - }); - - it("names the other project the anchored session belongs to, rather than failing", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], - currentSessionId: "s-elsewhere", - anchorInAnotherProject: "git@github.com:acme/other.git", - }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("unknown"); - expect(hookFired?.reason).toBe("anchor-in-another-project"); - expect(hookFired?.detail).toContain("git@github.com:acme/other.git"); - }); - - it("still fails a session left unproven when no other project claims the anchor", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], - currentSessionId: "s-current", - }) - ); - - expect(claim(result, "hook-fired")?.reason).toBe("session-left-no-run-file"); - }); - - it("prefers the other project over an untrusted Codex hook", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], - currentSessionId: "codex-elsewhere", - anchorInAnotherProject: "git@github.com:acme/other.git", - hookTrust: { readable: true, trusted: false, configPath: "/home/.codex/config.toml" }, - }) - ); - - expect(claim(result, "hook-fired")?.reason).toBe("anchor-in-another-project"); - }); - - it("cannot tell whether this session's hook fired without an anchor", () => { - const result = diagnoseTelemetryClaims( - evidence({ journals: [journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" })] }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("unknown"); - expect(hookFired?.reason).toBe("no-session-anchor"); - }); - - it("names an untrusted Codex hook, not never having fired, when the trust state says so", () => { - const result = diagnoseTelemetryClaims( - evidence({ - currentSessionId: "codex-1", - hookTrust: { readable: true, trusted: false, configPath: "/home/.codex/config.toml" }, - }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("fail"); - expect(hookFired?.reason).toBe("untrusted-codex-hook"); - expect(hookFired?.detail).toContain("--dangerously-bypass-hook-trust"); - }); - - it("still names the generic never-fired fault once the hook is actually trusted", () => { - const result = diagnoseTelemetryClaims( - evidence({ - currentSessionId: "codex-1", - hookTrust: { readable: true, trusted: true, configPath: "/home/.codex/config.toml" }, - }) - ); - expect(claim(result, "hook-fired")?.reason).toBe("recorder-declared-nowhere"); - }); - - it("says the trust state could not itself be read, rather than guessing", () => { - const result = diagnoseTelemetryClaims( - evidence({ - currentSessionId: "codex-1", - hookTrust: { - readable: false, - reason: "/home/.codex/config.toml could not be read (ENOENT)", - }, - }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.reason).toBe("recorder-declared-nowhere"); - expect(hookFired?.detail).toMatch(/could not be read either/u); - }); - - it("says nothing about trust for a tool with no trust gate", () => { - const result = diagnoseTelemetryClaims(evidence({ currentSessionId: "claude-1" })); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.detail).not.toMatch(/trust/u); - }); - - it("names an untrusted hook for this session too, when an older session left a run file but this one did not", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], - currentSessionId: "codex-current", - hookTrust: { readable: true, trusted: false, configPath: "/home/.codex/config.toml" }, - }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.reason).toBe("untrusted-codex-hook"); - expect(hookFired?.detail).not.toMatch(/this session left no run file/u); - }); - - it("reports nothing to evaluate, never a failure, when the recorder is declared but no run file has appeared", () => { - const result = diagnoseTelemetryClaims( - evidence({ currentSessionId: "s-1", recorderDeclared: true }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("unknown"); - expect(hookFired?.reason).toBe("recorder-declared-not-yet-fired"); - expect(hookFired?.detail).toMatch(/declaration is not proof/u); - expect(hookFired?.detail).toMatch(/claude-cli-adapter\.ts/u); - }); - - it("names the recorder as what is missing when it is declared nowhere and no run file has appeared", () => { - const result = diagnoseTelemetryClaims( - evidence({ currentSessionId: "s-1", recorderDeclared: false }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("fail"); - expect(hookFired?.detail).toMatch(/recorder is declared nowhere/u); - }); - - it("says it could not tell, never a failure, when the declaration itself could not be read", () => { - const result = diagnoseTelemetryClaims( - evidence({ - currentSessionId: "s-1", - recorderDeclared: false, - recorderDeclarationReadable: false, - }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.verdict).toBe("unknown"); - expect(hookFired?.reason).toBe("recorder-declaration-unreadable"); - expect(hookFired?.detail).toMatch(/could not be read/u); - expect(hookFired?.detail).not.toMatch(/declared nowhere/u); - }); - - it("lets an untrusted Codex hook explain the absence ahead of either new reason, even when the recorder is declared", () => { - const result = diagnoseTelemetryClaims( - evidence({ - currentSessionId: "codex-1", - recorderDeclared: true, - hookTrust: { readable: true, trusted: false, configPath: "/home/.codex/config.toml" }, - }) - ); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.reason).toBe("untrusted-codex-hook"); - }); -}); - -describe("diagnoseTelemetryClaims — session journalled", () => { - it("reads ok when a run file closed its turn", () => { - const result = diagnoseTelemetryClaims( - evidence({ journals: [journal({ vendorId: "s-1", turnClosed: true })] }) - ); - expect(claim(result, "session-journalled")?.verdict).toBe("ok"); - }); - - it("names a run file that carries only session_start", () => { - const result = diagnoseTelemetryClaims( - evidence({ journals: [journal({ vendorId: "s-1", turnClosed: false })] }) - ); - const c = claim(result, "session-journalled"); - expect(c?.verdict).toBe("fail"); - expect(c?.reason).toBe("only-session-start"); - }); - - it("has nothing to read when no run file exists", () => { - const result = diagnoseTelemetryClaims(evidence()); - const c = claim(result, "session-journalled"); - expect(c?.verdict).toBe("unknown"); - expect(c?.reason).toBe("no-run-file-to-read"); - }); -}); - -describe("diagnoseTelemetryClaims — tool files readable", () => { - it("reads ok once one covered tool found the session", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-1" })], - toolReads: [toolRead({ sessionFound: true })], - }) - ); - expect(claim(result, "tool-files-readable")?.verdict).toBe("ok"); - }); - - it("names no session found for any tool, while the journal names one", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-1" })], - toolReads: [toolRead({ tool: "claude" }), toolRead({ tool: "codex" })], - }) - ); - const c = claim(result, "tool-files-readable"); - expect(c?.verdict).toBe("fail"); - expect(c?.detail).toContain("s-1"); - }); - - it("has no session to look for when the journal names none", () => { - const result = diagnoseTelemetryClaims(evidence()); - expect(claim(result, "tool-files-readable")?.verdict).toBe("unknown"); - }); - - it("carries the count read against the count attempted, not just the tool that worked", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-1" })], - toolReads: [ - toolRead({ tool: "claude", sessionFound: true }), - toolRead({ tool: "claude", sessionFound: false }), - ], - }) - ); - const c = claim(result, "tool-files-readable"); - expect(c?.verdict).toBe("ok"); - expect(c?.detail).toContain("claude: 1 of 2 session(s) read"); - }); - - it("names a reader that threw as failing to read, not as a plain miss", () => { - const result = diagnoseTelemetryClaims( - evidence({ - journals: [journal({ vendorId: "s-1" })], - toolReads: [ - toolRead({ tool: "claude", sessionFound: false }), - toolRead({ tool: "codex", sessionFound: false, error: "ENOENT: no such file" }), - ], - }) - ); - expect(claim(result, "tool-files-readable")?.detail).toContain( - "1 read attempt(s) failed: ENOENT" - ); - }); -}); - -describe("diagnoseTelemetryClaims — records join", () => { - it("reads ok once a record joined a step", () => { - const result = diagnoseTelemetryClaims( - evidence({ - toolReads: [ - toolRead({ hasIntervals: true, records: [{ stepAttribution: "journal-interval" }] }), - ], - }) - ); - expect(claim(result, "records-join")?.verdict).toBe("ok"); - }); - - it("names every record unattributed, not a missing record", () => { - const result = diagnoseTelemetryClaims( - evidence({ - toolReads: [ - toolRead({ - hasIntervals: true, - records: [{ stepAttribution: "unattributed" }, { stepAttribution: "unattributed" }], - }), - ], - }) - ); - const c = claim(result, "records-join"); - expect(c?.verdict).toBe("fail"); - expect(c?.reason).toBe("all-unattributed"); - }); - - it("has nothing to join when no record was read", () => { - const result = diagnoseTelemetryClaims(evidence({ toolReads: [toolRead({ records: [] })] })); - const c = claim(result, "records-join"); - expect(c?.verdict).toBe("unknown"); - expect(c?.reason).toBe("no-record-to-join"); - }); - - it("has nothing to join when neither a step interval nor a tool-stated step exists", () => { - const result = diagnoseTelemetryClaims( - evidence({ - toolReads: [ - toolRead({ hasIntervals: false, records: [{ stepAttribution: "unattributed" }] }), - ], - }) - ); - const c = claim(result, "records-join"); - expect(c?.verdict).toBe("unknown"); - expect(c?.reason).toBe("no-join-material"); - }); -}); - -describe("diagnoseTelemetryClaims — the whole set", () => { - it("always returns exactly four claims, in the fixed order, none of them ever unjudged", () => { - const result = diagnoseTelemetryClaims(evidence()); - expect(result.map((c) => c.claim)).toEqual([ - "hook-fired", - "session-journalled", - "tool-files-readable", - "records-join", - ]); - for (const c of result) expect(["ok", "fail", "unknown"]).toContain(c.verdict); - }); - - it("no claim mentions exporting, a destination, or an identity attribute", () => { - const result = diagnoseTelemetryClaims(evidence()); - for (const c of result) { - expect(c.detail.toLowerCase()).not.toMatch(/export|endpoint|identifier|identity/u); - } - }); - - it("no claim recommends a command the system no longer offers", () => { - const result = diagnoseTelemetryClaims(evidence()); - for (const c of result) { - expect(c.detail.toLowerCase()).not.toMatch(/\breceive\b|\bendpoint\b/u); - } - }); -}); - -// A shape change whose consumer is not updated in the same commit is how the cost skill was -// halted twice, so this reads the skill's prose as text, the way a person or an agent would. -describe("the diagnostic skill states the claims the command prints, in the number it prints them", () => { - const SKILL_DIR = resolve(REPOSITORY_ROOT, "plugins", "aidd-telemetry", "skills", "02-check"); - const CLAIM_COUNT_FILES = [ - resolve(SKILL_DIR, "SKILL.md"), - resolve(SKILL_DIR, "actions", "02-diagnose.md"), - ]; - - const NUMBER_WORDS: Record = { - one: 1, - two: 2, - three: 3, - four: 4, - five: 5, - six: 6, - seven: 7, - eight: 8, - }; - - // Every cardinal " claim(s)" mention in one file, not only the first: 02-diagnose.md - // states the count three times, and stopping at the first would leave the rest free to drift. - function statedClaimCounts(path: string): readonly number[] { - const text = readFileSync(path, "utf8"); - const matches = [ - ...text.matchAll(/\b(one|two|three|four|five|six|seven|eight)\s+claims?\b/giu), - ]; - if (matches.length === 0) { - throw new Error( - `${path} no longer states a claim count in words — update this check's regex ` + - "(and keep it able to fail) if the wording changed on purpose" - ); - } - return matches.map((match) => { - const word = match[1]?.toLowerCase() ?? ""; - const count = NUMBER_WORDS[word]; - if (count === undefined) throw new Error(`unrecognised claim-count word: ${match[1]}`); - return count; - }); - } - - it("agrees with the actual number of claims diagnoseTelemetryClaims prints, everywhere it is stated", () => { - const actual = diagnoseTelemetryClaims(evidence()).length; - for (const path of CLAIM_COUNT_FILES) { - for (const stated of statedClaimCounts(path)) { - expect(stated).toBe(actual); - } - } - }); -}); - -/** A marker phrase read from the live code's own detail text is required in `02-diagnose.md`'s - * step 6 alone, tied to its verdict token in the same sentence, so a change on either side - * alone fails; a missing `NoRunFileReason` entry is a compile error. */ -describe("the diagnostic skill's account of every no-run-file reason matches the command's own reasons", () => { - const DIAGNOSE_MD = resolve( - REPOSITORY_ROOT, - "plugins", - "aidd-telemetry", - "skills", - "02-check", - "actions", - "02-diagnose.md" - ); - const DIAGNOSE_TEXT = readFileSync(DIAGNOSE_MD, "utf8"); - - // Step 6 alone, from its header to the next numbered step: the one place that teaches which - // token pairs with which reason, not the Test table restating it. - const STEP_SIX_HEADER = "No run file yet is not always a failure"; - const stepSixMatch = DIAGNOSE_TEXT.match( - new RegExp(`${STEP_SIX_HEADER}[\\s\\S]*?\\n\\d+\\.\\s+\\*\\*`, "u") - ); - if (stepSixMatch === null) { - throw new Error(`${DIAGNOSE_MD} no longer has a step opening with "${STEP_SIX_HEADER}"`); - } - const STEP_SIX_TEXT = stepSixMatch[0]; - - const VERDICT_TOKEN: Record = { - ok: "ok", - fail: "FAIL", - unknown: "--", - }; - - interface NoRunFileAccount { - readonly verdict: TelemetryClaimVerdict; - readonly phrase: string; - readonly evidenceOverrides: Partial; - } - - const ACCOUNTS: Record = { - "recorder-declared-not-yet-fired": { - verdict: "unknown", - phrase: "nothing to evaluate", - evidenceOverrides: { currentSessionId: "s-1", recorderDeclared: true }, - }, - "recorder-declared-nowhere": { - verdict: "fail", - phrase: "declared nowhere", - evidenceOverrides: { currentSessionId: "s-1", recorderDeclared: false }, - }, - "recorder-declaration-unreadable": { - verdict: "unknown", - phrase: "could not be read", - evidenceOverrides: { - currentSessionId: "s-1", - recorderDeclared: false, - recorderDeclarationReadable: false, - }, - }, - "anchorless-run-file": { - verdict: "fail", - phrase: "none carry a readable session_start", - evidenceOverrides: { currentSessionId: "s-1", journals: [journal({ vendorId: undefined })] }, - }, - "journal-in-another-schema": { - verdict: "fail", - phrase: "written under a schema this build does not read", - evidenceOverrides: { currentSessionId: "s-1", foreignSchemaVersions: [3] }, - }, - }; - - // One bullet is one sentence in step 6, but not always in token-then-phrase order, so this - // bounds the clause by the nearest period instead of assuming one. - function clauseContaining(text: string, phrase: string): string { - const idx = text.toLowerCase().indexOf(phrase.toLowerCase()); - if (idx === -1) throw new Error(`step 6 of ${DIAGNOSE_MD} never mentions "${phrase}"`); - const start = text.lastIndexOf(".", idx) + 1; - const relEnd = text.slice(idx).indexOf("."); - const end = relEnd === -1 ? text.length : idx + relEnd + 1; - return text.slice(start, end); - } - - for (const [reason, account] of Object.entries(ACCOUNTS) as [ - NoRunFileReason, - NoRunFileAccount, - ][]) { - describe(reason, () => { - it("is what the live code actually produces", () => { - const result = diagnoseTelemetryClaims(evidence(account.evidenceOverrides)); - const hookFired = claim(result, "hook-fired"); - expect(hookFired?.reason).toBe(reason); - expect(hookFired?.verdict).toBe(account.verdict); - expect(hookFired?.detail.toLowerCase()).toContain(account.phrase.toLowerCase()); - }); - - it("is what step 6 of the skill teaches, in the same sentence as its own verdict token", () => { - const clause = clauseContaining(STEP_SIX_TEXT, account.phrase); - expect(clause).toContain(`\`${VERDICT_TOKEN[account.verdict]}\``); - }); - - it("is never paired with the wrong verdict token in step 6", () => { - const wrongToken = account.verdict === "fail" ? "--" : "FAIL"; - const clause = clauseContaining(STEP_SIX_TEXT, account.phrase); - expect(clause).not.toContain(`\`${wrongToken}\``); - }); - }); - } -}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts deleted file mode 100644 index 488a0fb62..000000000 --- a/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { findLeftoverExportKeys } from "../../../../src/contexts/telemetry/domain/telemetry-export-leftover.js"; - -describe("findLeftoverExportKeys", () => { - it("names every known export key present in the file's env block", () => { - const content = JSON.stringify({ - env: { - CLAUDE_CODE_ENABLE_TELEMETRY: "1", - OTEL_EXPORTER_OTLP_ENDPOINT: "https://otel.example.com", - SOME_OTHER_VAR: "keep-me", - }, - }); - expect(findLeftoverExportKeys(content)).toEqual([ - "CLAUDE_CODE_ENABLE_TELEMETRY", - "OTEL_EXPORTER_OTLP_ENDPOINT", - ]); - }); - - it("finds nothing in a file with no env block", () => { - expect(findLeftoverExportKeys(JSON.stringify({ permissions: {} }))).toEqual([]); - }); - - it("finds nothing in an env block that carries none of the known keys", () => { - const content = JSON.stringify({ env: { MY_OWN_VAR: "1" } }); - expect(findLeftoverExportKeys(content)).toEqual([]); - }); - - it("reads a file that does not exist (null) as nothing found", () => { - expect(findLeftoverExportKeys(null)).toEqual([]); - }); - - it("reads unparseable content as nothing found, rather than throwing", () => { - expect(findLeftoverExportKeys("{not json")).toEqual([]); - }); - - it("reads an env block that is not an object as nothing found", () => { - expect(findLeftoverExportKeys(JSON.stringify({ env: "not-an-object" }))).toEqual([]); - }); - - it("reads a file whose whole content is a JSON array as nothing found, rather than throwing", () => { - expect(findLeftoverExportKeys("[1, 2]")).toEqual([]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts deleted file mode 100644 index b21f7f68d..000000000 --- a/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type TelemetryRemovalPreview, - telemetryRemovalIsEmpty, -} from "../../../../src/contexts/telemetry/domain/telemetry-removal.js"; - -function preview(overrides: Partial = {}): TelemetryRemovalPreview { - return { - journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, - sink: { scope: "machine", path: "/home/.config/aidd/telemetry", dayFileNames: [] }, - identity: { - scope: "machine", - path: "/home/.config/aidd/identity.json", - present: false, - unreadable: false, - }, - history: { certainty: "possible" }, - ...overrides, - }; -} - -describe("TelemetryRemovalPreview — a project's journal and a machine's records are never the same kind of thing", () => { - it("names the journal as this project's own", () => { - const result = preview(); - expect(result.journal.scope).toBe("project"); - }); - - it("names the sink and the identity file as this machine's own", () => { - const result = preview(); - expect(result.sink.scope).toBe("machine"); - expect(result.identity.scope).toBe("machine"); - }); - - it("carries what cannot be reached beside what can, on the same value", () => { - const result = preview({ - history: { certainty: "committed", files: ["aidd_docs/runs/x.jsonl"] }, - }); - expect(result.history.certainty).toBe("committed"); - // Not optional: a caller reading `result.journal` also has `result.history` in hand. - expect(result).toHaveProperty("history"); - }); -}); - -describe("telemetryRemovalIsEmpty()", () => { - it("is empty when every location has nothing", () => { - expect(telemetryRemovalIsEmpty(preview())).toBe(true); - }); - - it("is not empty when the journal holds a run file", () => { - expect( - telemetryRemovalIsEmpty( - preview({ - journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: ["a.jsonl"] }, - }) - ) - ).toBe(false); - }); - - it("is not empty when the sink holds a day file", () => { - expect( - telemetryRemovalIsEmpty( - preview({ sink: { scope: "machine", path: "/sink", dayFileNames: ["2026-08-20.jsonl"] } }) - ) - ).toBe(false); - }); - - it("is not empty when an identity is present, even if damaged", () => { - expect( - telemetryRemovalIsEmpty( - preview({ - identity: { scope: "machine", path: "/identity.json", present: true, unreadable: true }, - }) - ) - ).toBe(false); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-setup.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-setup.unit.test.ts deleted file mode 100644 index e438da297..000000000 --- a/cli/tests/contexts/telemetry/domain/telemetry-setup.unit.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildTelemetryAllowedSetup, - detectHookManager, -} from "../../../../src/contexts/telemetry/domain/telemetry-setup.js"; - -const SWITCH_PATH = "/repo/.aidd/config.json"; - -describe("buildTelemetryAllowedSetup — whose choice this was", () => { - it("reads a project's own switch, turned on, as the project's decision", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: true, readable: true }, - {} - ); - expect(setup).toEqual({ - allowed: true, - decidedBy: "project-switch", - location: SWITCH_PATH, - readable: true, - }); - }); - - it("reads a project never switched on as the project's own decision, not a refusal", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: false, readable: true }, - {} - ); - expect(setup.allowed).toBe(false); - expect(setup.decidedBy).toBe("project-switch"); - }); - - it("reads AIDD_TELEMETRY=0 as this person's own refusal, whatever the project file says", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: true, readable: true }, - { AIDD_TELEMETRY: "0" } - ); - expect(setup).toEqual({ - allowed: false, - decidedBy: "person-refusal", - location: "AIDD_TELEMETRY", - readable: true, - }); - }); - - it("never lets a damaged switch file masquerade as a refusal", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: false, readable: false }, - {} - ); - expect(setup.decidedBy).toBe("project-switch"); - expect(setup.readable).toBe(false); - }); - - it("reads a person's refusal as always readable — an env var never fails to read", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: false, readable: false }, - { AIDD_TELEMETRY: "0" } - ); - expect(setup.readable).toBe(true); - }); - - it("never treats an unset AIDD_TELEMETRY as a refusal", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: true, readable: true }, - { AIDD_TELEMETRY: "" } - ); - expect(setup.decidedBy).toBe("project-switch"); - }); -}); - -/** From root marker files alone: a manager regenerates `prepare-commit-msg` from its own - * config on every install, so the hook's own contents say nothing by the time this runs. */ -describe("detectHookManager — which manager owns prepare-commit-msg, from the root alone", () => { - it("reads lefthook.yml as lefthook", () => { - expect(detectHookManager(["lefthook.yml"])).toBe("lefthook"); - }); - - it("reads every dot-prefixed and .yaml spelling lefthook itself accepts", () => { - expect(detectHookManager([".lefthook.yaml"])).toBe("lefthook"); - expect(detectHookManager(["lefthook.yaml"])).toBe("lefthook"); - expect(detectHookManager([".lefthook.yml"])).toBe("lefthook"); - }); - - it("reads .husky as husky", () => { - expect(detectHookManager([".husky"])).toBe("husky"); - }); - - it("prefers lefthook when both markers are present — a deterministic, documented tie-break", () => { - expect(detectHookManager(["lefthook.yml", ".husky"])).toBe("lefthook"); - }); - - it("names neither manager when no marker is present", () => { - expect(detectHookManager([])).toBeUndefined(); - expect(detectHookManager(["package.json", "README.md"])).toBeUndefined(); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts deleted file mode 100644 index 4ff7d2bc1..000000000 --- a/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - parseTelemetrySinkLine, - SINK_SCHEMA_VERSION, - type TelemetrySinkRecord, - telemetrySinkRecordDayKey, -} from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { UnknownTelemetrySinkSchemaVersionError } from "../../../../src/kernel/errors.js"; - -describe("parseTelemetrySinkLine()", () => { - it("rejects an unknown sink_schema_version rather than guessing its shape", () => { - expect(() => - parseTelemetrySinkLine(JSON.stringify({ sink_schema_version: 999, kind: "request" })) - ).toThrow(UnknownTelemetrySinkSchemaVersionError); - }); - - // The literal version this schema moved past — v1 carried no `provenance`, so guessing - // one for it would be exactly the false "old route" default the field exists to forbid. - it("rejects the v1 shape specifically, not just an unrecognised number", () => { - expect(() => - parseTelemetrySinkLine( - JSON.stringify({ sink_schema_version: 1, kind: "request", vendor_id: "s-1" }) - ) - ).toThrow(UnknownTelemetrySinkSchemaVersionError); - }); - - it("parses a hand-written fixture the mapper never produced", () => { - const url = new URL("../../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); - const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); - const records = lines.map(parseTelemetrySinkLine); - - const requestLine = records.find((r) => r.kind === "request"); - expect(requestLine?.vendor_id).toBeTruthy(); - expect(requestLine?.vendor_field).toBeTruthy(); - expect(requestLine?.cost_usd).toBeGreaterThan(0); - expect(requestLine?.model).toBeTruthy(); - - const sessionLine = records.find((r) => r.kind === "session" && r.active_time_s !== undefined); - expect(sessionLine?.active_time_s).toBeGreaterThan(0); - expect(sessionLine?.turn_id).toBeUndefined(); - }); - - // The fixture carries `user_id` on purpose: the sink is append-only, so a line an earlier - // build wrote keeps the field forever and parsing must not choke on it. - it("parses a stored line that still carries the now-removed user_id, inertly", () => { - const url = new URL("../../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); - const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); - expect(lines.some((line) => line.includes("user_id"))).toBe(true); - - const records = lines.map(parseTelemetrySinkLine); - const legacy = records.find((r) => "user_id" in r); - // `in` narrows the parsed record to one that still carries the field, so the value - // below is read off a type - and the fixture losing the line fails here, loudly. - if (legacy === undefined || !("user_id" in legacy)) { - throw new Error("fixture no longer carries a user_id line"); - } - expect(legacy.user_id).toBe("user_example_hash_0000000000000000"); - }); - - it("carries provenance for both routes, on the same fixture", () => { - const url = new URL("../../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); - const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); - const records = lines.map(parseTelemetrySinkLine); - expect(records.some((r) => r.provenance === "export")).toBe(true); - expect(records.some((r) => r.provenance === "local-read")).toBe(true); - }); - - // This fixture predates cli_version entirely: an unknown version costs a field, never a - // figure, so every record on it must still be there to count. - it("parses a line written before cli_version existed, losing no figure to the gap", () => { - const url = new URL("../../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); - const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); - expect(lines.some((line) => line.includes("cli_version"))).toBe(false); - - const records = lines.map(parseTelemetrySinkLine); - expect(records).toHaveLength(lines.length); - for (const record of records) { - expect("cli_version" in record).toBe(false); - } - }); -}); - -describe("telemetrySinkRecordDayKey()", () => { - const BASE: TelemetrySinkRecord = { - sink_schema_version: SINK_SCHEMA_VERSION, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - }; - - it("answers the UTC day for a real moment, whatever its offset", () => { - expect(telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-08-18T01:00:00Z" })).toBe( - "2026-08-18" - ); - expect( - telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-08-18T01:00:00+05:00" }) - ).toBe("2026-08-17"); - }); - - it("rolls 24:00 over to the next day, as the moment it names does", () => { - expect(telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-09-10T24:00:00Z" })).toBe( - "2026-09-11" - ); - }); - - it("answers a calendar day for a parseable moment not written in ISO form", () => { - expect( - telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "Thu, 10 Sep 2026 23:00:00 Z" }) - ).toBe("2026-09-10"); - expect(telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "+002026-09-10T10:00:00Z" })).toBe( - "2026-09-10" - ); - }); - - it("answers undefined for no moment at all", () => { - expect(telemetrySinkRecordDayKey({ ...BASE })).toBeUndefined(); - }); - - // A string merely shaped like a moment must not take the fast slice path: a record filed - // under a fragment nothing on the calendar matches stays in `totals` but leaves `byDays`. - it("answers undefined for a string merely shaped like a moment, never a sliced fragment", () => { - expect( - telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "not-a-momentZ" }) - ).toBeUndefined(); - }); -}); - -describe("telemetrySinkRecordDayKey() — a line holds whatever it holds", () => { - const BASE: TelemetrySinkRecord = { - sink_schema_version: SINK_SCHEMA_VERSION, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - }; - - /** Built through the real parse, which checks `sink_schema_version` and casts the rest — - * the only way a record whose field is a string by convention alone ever gets here. */ - function recordFromLine(overrides: Record): TelemetrySinkRecord { - return parseTelemetrySinkLine(JSON.stringify({ ...BASE, ...overrides })); - } - - it("answers nothing for a moment stored as a number, never 1970", () => { - expect(telemetrySinkRecordDayKey(recordFromLine({ event_timestamp: 12_345 }))).toBeUndefined(); - }); - - it("answers nothing for a moment stored as null", () => { - expect(telemetrySinkRecordDayKey(recordFromLine({ event_timestamp: null }))).toBeUndefined(); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-sink-retention.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-sink-retention.unit.test.ts deleted file mode 100644 index 509acdcdd..000000000 --- a/cli/tests/contexts/telemetry/domain/telemetry-sink-retention.unit.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - DEFAULT_TELEMETRY_SINK_RETENTION_DAYS, - decideTelemetrySinkRetention, -} from "../../../../src/contexts/telemetry/domain/telemetry-sink-retention.js"; - -describe("decideTelemetrySinkRetention()", () => { - it("keeps the window's files and prunes the oldest, on real file names", () => { - const files = ["2026-08-01.jsonl", "2026-08-02.jsonl", "2026-08-03.jsonl", "2026-08-04.jsonl"]; - const decision = decideTelemetrySinkRetention(files, 2); - expect(decision.keep).toEqual(["2026-08-03.jsonl", "2026-08-04.jsonl"]); - expect(decision.prune).toEqual(["2026-08-01.jsonl", "2026-08-02.jsonl"]); - }); - - it("defaults to a ninety-day window", () => { - expect(DEFAULT_TELEMETRY_SINK_RETENTION_DAYS).toBe(90); - }); - - it("never prunes the newest file, even at a window of 0", () => { - const files = ["2026-08-01.jsonl", "2026-08-02.jsonl", "2026-08-03.jsonl"]; - const decision = decideTelemetrySinkRetention(files, 0); - expect(decision.keep).toEqual(["2026-08-03.jsonl"]); - expect(decision.prune).not.toContain("2026-08-03.jsonl"); - }); - - it("touches nothing when the sink is younger than the window", () => { - const files = ["2026-08-03.jsonl", "2026-08-04.jsonl"]; - const decision = decideTelemetrySinkRetention(files, 90); - expect(decision.keep).toEqual(files); - expect(decision.prune).toEqual([]); - }); - - it("accepts files out of order and still sorts chronologically", () => { - const files = ["2026-08-04.jsonl", "2026-08-01.jsonl", "2026-08-03.jsonl", "2026-08-02.jsonl"]; - const decision = decideTelemetrySinkRetention(files, 1); - expect(decision.keep).toEqual(["2026-08-04.jsonl"]); - expect(decision.prune).toEqual(["2026-08-01.jsonl", "2026-08-02.jsonl", "2026-08-03.jsonl"]); - }); -}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts deleted file mode 100644 index 7c950c465..000000000 --- a/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - buildTelemetrySwitchFile, - parseTelemetrySwitchFile, - personRefusesTelemetry, - resolveTelemetryEnabled, - type TelemetrySwitch, - telemetryConfigPath, -} from "../../../../src/contexts/telemetry/domain/telemetry-switch.js"; -import { journalRepo } from "../../../helpers/telemetry-journal-hook.js"; - -describe("telemetryConfigPath", () => { - it("resolves .aidd/config.json under the project root", () => { - expect(telemetryConfigPath("/repo")).toBe(join("/repo", ".aidd", "config.json")); - }); -}); - -describe("parseTelemetrySwitchFile", () => { - it("reads enabled and endpoint from a well-formed switch", () => { - const config = parseTelemetrySwitchFile( - JSON.stringify({ telemetry: { enabled: true, endpoint: "https://otel.example.com" } }) - ); - expect(config).toEqual({ enabled: true, endpoint: "https://otel.example.com" }); - }); - - it("reads enabled: false without an endpoint", () => { - const config = parseTelemetrySwitchFile(JSON.stringify({ telemetry: { enabled: false } })); - expect(config).toEqual({ enabled: false, endpoint: undefined }); - }); - - it("treats a non-boolean-true enabled value as off, not a throw", () => { - const config = parseTelemetrySwitchFile(JSON.stringify({ telemetry: { enabled: "yes" } })); - expect(config?.enabled).toBe(false); - }); - - it("returns null for unparseable JSON — the same failure direction as the hook", () => { - expect(parseTelemetrySwitchFile("not json")).toBeNull(); - }); - - it("returns null when the telemetry key is absent", () => { - expect(parseTelemetrySwitchFile(JSON.stringify({ other: true }))).toBeNull(); - }); - - it("returns null when the telemetry key has the wrong shape", () => { - expect(parseTelemetrySwitchFile(JSON.stringify({ telemetry: "on" }))).toBeNull(); - expect(parseTelemetrySwitchFile(JSON.stringify({ telemetry: [1, 2] }))).toBeNull(); - }); -}); - -describe("personRefusesTelemetry", () => { - it("only the literal string '0' is a refusal", () => { - expect(personRefusesTelemetry({ AIDD_TELEMETRY: "0" })).toBe(true); - }); - - it("unset, empty, or any other value is not a choice — never a refusal", () => { - expect(personRefusesTelemetry({})).toBe(false); - expect(personRefusesTelemetry({ AIDD_TELEMETRY: "" })).toBe(false); - expect(personRefusesTelemetry({ AIDD_TELEMETRY: "1" })).toBe(false); - expect(personRefusesTelemetry({ AIDD_TELEMETRY: "false" })).toBe(false); - expect(personRefusesTelemetry({ AIDD_TELEMETRY: "no" })).toBe(false); - }); -}); - -describe("resolveTelemetryEnabled", () => { - const ON: TelemetrySwitch = { enabled: true }; - const OFF: TelemetrySwitch = { enabled: false }; - - it("the refusal wins over a project that turned measurement on", () => { - expect(resolveTelemetryEnabled(ON, { AIDD_TELEMETRY: "0" })).toBe(false); - }); - - it("no refusal, project on — enabled", () => { - expect(resolveTelemetryEnabled(ON, {})).toBe(true); - }); - - it("no refusal, project off or absent — not enabled", () => { - expect(resolveTelemetryEnabled(OFF, {})).toBe(false); - expect(resolveTelemetryEnabled(null, {})).toBe(false); - }); - - it("an unset refusal never turns measurement on by itself", () => { - expect(resolveTelemetryEnabled(null, { AIDD_TELEMETRY: "" })).toBe(false); - }); -}); - -describe("the hook (repo.cjs) and the CLI agree, for every combination", () => { - let repoRoot: string; - const originalEnv = process.env.AIDD_TELEMETRY; - - beforeEach(async () => { - repoRoot = await mkdtemp(join(tmpdir(), "telemetry-switch-parity-")); - await mkdir(join(repoRoot, ".aidd"), { recursive: true }); - }); - - afterEach(async () => { - await rm(repoRoot, { recursive: true, force: true }); - if (originalEnv === undefined) delete process.env.AIDD_TELEMETRY; - else process.env.AIDD_TELEMETRY = originalEnv; - }); - - async function seedSwitch(enabled: boolean | null): Promise { - if (enabled === null) return null; - await writeFile( - join(repoRoot, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled } }) - ); - return { enabled }; - } - - const CASES: readonly { - readonly label: string; - readonly fileEnabled: boolean | null; - readonly envValue: string | undefined; - readonly expected: boolean; - }[] = [ - { label: "file on, no refusal", fileEnabled: true, envValue: undefined, expected: true }, - { label: "file off, no refusal", fileEnabled: false, envValue: undefined, expected: false }, - { label: "no file, no refusal", fileEnabled: null, envValue: undefined, expected: false }, - { label: "file on, refused", fileEnabled: true, envValue: "0", expected: false }, - { label: "file off, refused", fileEnabled: false, envValue: "0", expected: false }, - { label: "no file, refused", fileEnabled: null, envValue: "0", expected: false }, - { - label: "file on, empty refusal is not a choice", - fileEnabled: true, - envValue: "", - expected: true, - }, - { - label: "file on, any other value is not a choice", - fileEnabled: true, - envValue: "1", - expected: true, - }, - ]; - - for (const { label, fileEnabled, envValue, expected } of CASES) { - it(`${label} => ${expected}`, async () => { - const fileSwitch = await seedSwitch(fileEnabled); - if (envValue === undefined) delete process.env.AIDD_TELEMETRY; - else process.env.AIDD_TELEMETRY = envValue; - - const hookResult = journalRepo.telemetryEnabled(repoRoot); - const cliResult = resolveTelemetryEnabled(fileSwitch, process.env); - - expect(hookResult).toBe(expected); - expect(cliResult).toBe(expected); - }); - } -}); - -describe("buildTelemetrySwitchFile", () => { - it("writes enabled and endpoint from nothing", () => { - const content = buildTelemetrySwitchFile(null, { - enabled: true, - endpoint: "https://otel.example.com", - }); - expect(JSON.parse(content)).toEqual({ - telemetry: { enabled: true, endpoint: "https://otel.example.com" }, - }); - }); - - it("omits the endpoint key when none is given", () => { - const content = buildTelemetrySwitchFile(null, { enabled: false }); - expect(JSON.parse(content)).toEqual({ telemetry: { enabled: false } }); - }); - - it("preserves unrelated top-level keys already in the file", () => { - const existing = JSON.stringify({ other: { nested: true } }, null, 2); - const content = buildTelemetrySwitchFile(existing, { - enabled: true, - endpoint: "https://otel.example.com", - }); - const parsed = JSON.parse(content); - expect(parsed.other).toEqual({ nested: true }); - expect(parsed.telemetry).toEqual({ enabled: true, endpoint: "https://otel.example.com" }); - }); - - it("falls back to an empty root when the existing content is unparseable", () => { - const content = buildTelemetrySwitchFile("not json", { - enabled: true, - endpoint: "https://otel.example.com", - }); - expect(JSON.parse(content)).toEqual({ - telemetry: { enabled: true, endpoint: "https://otel.example.com" }, - }); - }); -}); - -describe("parseTelemetrySwitchFile — an endpoint that is not a string", () => { - it("reads no endpoint from a number, rather than carrying the number", () => { - const config = parseTelemetrySwitchFile( - JSON.stringify({ telemetry: { enabled: true, endpoint: 42 } }) - ); - - expect(config).toStrictEqual({ enabled: true, endpoint: undefined }); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.integration.test.ts deleted file mode 100644 index d918128db..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.integration.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { CopilotCostReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.js"; - -// The fixture mirrors a real $HOME: `.copilot/session-state//events.jsonl` sits where a -// real machine writes it, so homeDir here exercises the path built against an installation. -const HOME_DIR = fileURLToPath(new URL("../../../fixtures/local-cost", import.meta.url)).replace( - /\/$/, - "" -); - -const SESSION = "33333333-3333-4333-8333-333333333333"; -const EMPTY_SESSION = "44444444-4444-4444-8444-444444444444"; - -describe("CopilotCostReaderAdapter", () => { - const adapter = new CopilotCostReaderAdapter(HOME_DIR); - - it("reads one session record from its own events.jsonl, stamped with the id it was asked for", async () => { - const { records, sessionFound } = await adapter.read(SESSION); - - expect(sessionFound).toBe(true); - expect(records).toHaveLength(1); - expect(records[0]?.vendor_id).toBe(SESSION); - expect(records[0]?.kind).toBe("session"); - }); - - it("finds the session and reports it empty, not missing, when shutdown carried no tokenDetails", async () => { - expect(await adapter.read(EMPTY_SESSION)).toEqual({ records: [], sessionFound: true }); - }); - - it("says it found no session, not that the session cost nothing, when no directory names it", async () => { - expect(await adapter.read("no-such-session")).toEqual({ records: [], sessionFound: false }); - }); - - it("answers with nothing, not an error, when the declared home does not exist", async () => { - const adapterWithNoHome = new CopilotCostReaderAdapter(`${HOME_DIR}/does-not-exist`); - - await expect(adapterWithNoHome.read(SESSION)).resolves.toEqual({ - records: [], - sessionFound: false, - }); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/git-adapter-telemetry-project-id.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/git-adapter-telemetry-project-id.integration.test.ts deleted file mode 100644 index 9fca023d0..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/git-adapter-telemetry-project-id.integration.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { journalRepo } from "../../../helpers/telemetry-journal-hook.js"; - -// git exports GIT_DIR into every process it spawns, and the journal hook runs from inside -// one: without the strip, a hook-started session tags every record with the wrong project. -describe("the journal hook does not follow a leaked GIT_DIR", () => { - const created: string[] = []; - const savedGitDir = process.env.GIT_DIR; - - afterEach(() => { - if (savedGitDir === undefined) delete process.env.GIT_DIR; - else process.env.GIT_DIR = savedGitDir; - for (const dir of created) rmSync(dir, { recursive: true, force: true }); - created.length = 0; - }); - - function makeRepo(remoteUrl: string): string { - const dir = mkdtempSync(join(tmpdir(), "aidd-gitdir-")); - created.push(dir); - const env = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")) - ); - execFileSync("git", ["init", "-q", "."], { cwd: dir, env }); - execFileSync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir, env }); - return dir; - } - - it("reads the remote of the repository at cwd, not the one GIT_DIR names", () => { - const elsewhere = makeRepo("git@github.com:leaked/elsewhere.git"); - const here = makeRepo("git@github.com:expected/here.git"); - - process.env.GIT_DIR = join(elsewhere, ".git"); - - expect(journalRepo.deriveProjectId(here)).toBe("expected/here"); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts deleted file mode 100644 index 0e7776ebb..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { HookTrustReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.js"; - -/** - * Codex is the one host that declines to run a hook and says nothing about it, so this read - * is what tells "the hook is dead" apart from "the hook was never approved". - */ -const created: string[] = []; -const savedHome = process.env.HOME; - -afterEach(() => { - if (savedHome === undefined) delete process.env.HOME; - else process.env.HOME = savedHome; - for (const dir of created) rmSync(dir, { recursive: true, force: true }); - created.length = 0; -}); - -function codexHome(content?: string): string { - const home = mkdtempSync(join(tmpdir(), "aidd-hook-trust-")); - created.push(home); - process.env.HOME = home; - if (content !== undefined) { - mkdirSync(join(home, ".codex"), { recursive: true }); - writeFileSync(join(home, ".codex", "config.toml"), content); - } - return home; -} - -/** The exact key Codex writes once a hook is approved: the plugin, its hooks file, the - * event, and the two zeroes Codex appends. */ -const APPROVED_KEY = '[hooks.state."aidd-telemetry@1.0.0:hooks/hooks.json:session_start:0:0"]'; - -describe("reading whether Codex has been told it may run the recorder's hook", () => { - it("reads an approved hook as trusted", async () => { - codexHome(`${APPROVED_KEY}\ntrusted_hash = "abc123"\n`); - - expect(await new HookTrustReaderAdapter().read()).toEqual({ - readable: true, - configPath: join(process.env.HOME ?? "", ".codex", "config.toml"), - trusted: true, - }); - }); - - // The key on its own is Codex having seen the hook, not having approved it. Only the - // `trusted_hash` line under it says approved. - it("reads the key without its trusted_hash as not trusted", async () => { - codexHome(`${APPROVED_KEY}\nsomething_else = "1"\n`); - - const trust = await new HookTrustReaderAdapter().read(); - - expect(trust).toMatchObject({ readable: true, trusted: false }); - }); - - it("reads a trusted_hash line without the key above it as not trusted", async () => { - codexHome('trusted_hash = "abc123"\n'); - - expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); - }); - - it("reads the key wherever it sits in the file, not only on its first line", async () => { - codexHome(`[projects]\n[other]\n${APPROVED_KEY}\ntrusted_hash = "abc123"\n`); - - expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); - }); - - it("reads a trusted_hash written without spaces around its equals sign as trusted", async () => { - codexHome(`${APPROVED_KEY}\ntrusted_hash="abc123"\n`); - - expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); - }); - - it("reads an indented trusted_hash line as trusted", async () => { - codexHome(`${APPROVED_KEY}\n trusted_hash = "abc123"\n`); - - expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: true }); - }); - - it("reads a key whose name merely ends in trusted_hash as not trusted", async () => { - codexHome(`${APPROVED_KEY}\nnot_trusted_hash = "abc123"\n`); - - expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); - }); - - it("reads a config naming no hook of ours as not trusted", async () => { - codexHome( - '[hooks.state."someone-else@1.0.0:hooks/hooks.json:session_start:0:0"]\ntrusted_hash = "x"\n' - ); - - expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); - }); - - // A hook approved under a different event is a different key entirely, and reads as - // untrusted rather than as approval carried over from the name it used to have. - it("reads approval under another event as not trusting session_start", async () => { - codexHome( - '[hooks.state."aidd-telemetry@1.0.0:hooks/hooks.json:tool_used:0:0"]\ntrusted_hash = "x"\n' - ); - - expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); - }); - - // Unreadable is not untrusted: the first says nothing is known, the second is a fact about - // Codex's own state. - it("reads an absent config as unreadable, naming the path and the reason", async () => { - const home = codexHome(); - - const trust = await new HookTrustReaderAdapter().read(); - - expect(trust.readable).toBe(false); - expect("trusted" in trust).toBe(false); - expect(trust.readable === false ? trust.reason : "").toContain( - join(home, ".codex", "config.toml") - ); - expect(trust.readable === false ? trust.reason : "").toContain("ENOENT"); - }); - - // `HOME`, not `os.homedir()`: the two differ on Windows and under a sandboxed test, and - // reading the wrong one answers about a config nobody here wrote. - it("reads the home this process was told to use, never the machine's own", async () => { - const home = codexHome(`${APPROVED_KEY}\ntrusted_hash = "abc123"\n`); - - const trust = await new HookTrustReaderAdapter().read(); - - expect(trust.readable === true ? trust.configPath : "").toBe( - join(home, ".codex", "config.toml") - ); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts deleted file mode 100644 index f5312daf0..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; -import { OpencodeCostReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.js"; -import { OpencodeExportError } from "../../../../src/kernel/errors.js"; - -const SESSION_ID = "ses_test_read"; -const FIXTURE_PATH = fileURLToPath( - new URL("../../../fixtures/telemetry-sink/opencode-export.json", import.meta.url) -); - -/** Installs a real, executable `opencode` stand-in on an isolated PATH — no mock of - * `child_process`, so the real spawn and timeout machinery is exercised. */ -function installStandIn(scriptBody: string): { restore: () => void } { - const dir = mkdtempSync(join(tmpdir(), "aidd-opencode-bin-")); - writeFileSync(join(dir, "opencode"), scriptBody, { mode: 0o755 }); - const prevPath = process.env.PATH; - process.env.PATH = dir; - return { - restore: () => { - process.env.PATH = prevPath; - rmSync(dir, { recursive: true, force: true }); - }, - }; -} - -function emptyPath(): { restore: () => void } { - const dir = mkdtempSync(join(tmpdir(), "aidd-opencode-empty-")); - const prevPath = process.env.PATH; - process.env.PATH = dir; - return { - restore: () => { - process.env.PATH = prevPath; - rmSync(dir, { recursive: true, force: true }); - }, - }; -} - -// The isolated PATH used to install this stand-in holds nothing else, so every command the -// script calls — including `cat` and `sleep` below — needs its full, non-PATH-dependent path. -const WELL_BEHAVED_SCRIPT = `#!/bin/sh -if [ "$1" = "export" ] && [ "$3" = "--sanitize" ]; then - /bin/cat "${FIXTURE_PATH}" - exit 0 -fi -exit 1 -`; - -const UNKNOWN_SESSION_SCRIPT = `#!/bin/sh -echo "Exporting session: $2" 1>&2 -echo "Error: Session not found: $2" 1>&2 -exit 1 -`; - -const GENERIC_FAILURE_SCRIPT = `#!/bin/sh -echo "internal error: storage unavailable" 1>&2 -exit 2 -`; - -const SLOW_SCRIPT = `#!/bin/sh -/bin/sleep 3 -echo "{}" -exit 0 -`; - -describe("OpencodeCostReaderAdapter", () => { - let restorePath: (() => void) | undefined; - - afterEach(() => { - restorePath?.(); - restorePath = undefined; - }); - - it("returns nothing when the opencode binary is not on PATH", async () => { - const env = emptyPath(); - restorePath = env.restore; - - // No binary on the path is no trace of the session, never a session that cost nothing. - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).resolves.toEqual({ - records: [], - sessionFound: false, - }); - }); - - // The stand-in is a `#!/bin/sh` file with no extension: Windows resolves an executable by - // PATHEXT, not a shebang or the POSIX execute bit, so it never launches there. - const skipOnWindows = process.platform === "win32"; - - it.skipIf(skipOnWindows)( - "reads a well-behaved export into one record per billed message", - async () => { - const env = installStandIn(WELL_BEHAVED_SCRIPT); - restorePath = env.restore; - - const { records, sessionFound } = await new OpencodeCostReaderAdapter().read(SESSION_ID); - - expect(sessionFound).toBe(true); - // The fixture's fourth assistant message carries no `total` — never billed — so it - // yields no record. - expect(records).toHaveLength(3); - expect(records[0]).toMatchObject({ - kind: "request", - vendor_id: SESSION_ID, - vendor_field: "sessionID", - model: "claude-sonnet-4-6", - input_tokens: 3, - output_tokens: 115, - cache_read_tokens: 43639, - cache_creation_tokens: 3141, - }); - expect(records.every((r) => typeof r.turn_id === "string" && r.turn_id.length > 0)).toBe( - true - ); - } - ); - - it.skipIf(skipOnWindows)( - "says it found no session, not an error, for an unknown session", - async () => { - const env = installStandIn(UNKNOWN_SESSION_SCRIPT); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).resolves.toEqual({ - records: [], - sessionFound: false, - }); - } - ); - - it.skipIf(skipOnWindows)( - "throws OpencodeExportError, and stores nothing, on a non-zero exit unrelated to an unknown session", - async () => { - const env = installStandIn(GENERIC_FAILURE_SCRIPT); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( - OpencodeExportError - ); - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( - "storage unavailable" - ); - } - ); - - it.skipIf(skipOnWindows)( - "throws OpencodeExportError, and stores nothing, when the command exceeds its timeout", - async () => { - const env = installStandIn(SLOW_SCRIPT); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter(200).read(SESSION_ID)).rejects.toThrow( - OpencodeExportError - ); - } - ); - - it.skipIf(skipOnWindows)( - "names the spawn failure itself, not an exit code, when the command exceeds its timeout", - async () => { - const env = installStandIn(SLOW_SCRIPT); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter(200).read(SESSION_ID)).rejects.toThrow( - /^opencode export ses_test_read failed: spawnSync opencode ETIMEDOUT$/ - ); - } - ); - - it.skipIf(skipOnWindows)( - "names the exit code and the trimmed stderr on a generic failure", - async () => { - const env = installStandIn(GENERIC_FAILURE_SCRIPT); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( - /^opencode export ses_test_read exited with code 2: internal error: storage unavailable$/ - ); - } - ); - - it.skipIf(skipOnWindows)("says so when a failing command wrote nothing to stderr", async () => { - const env = installStandIn("#!/bin/sh\nexit 3\n"); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( - /^opencode export ses_test_read exited with code 3: no stderr output$/ - ); - }); - - it.skipIf(skipOnWindows)( - "reads a command killed by a signal as an unknown exit code", - async () => { - const env = installStandIn("#!/bin/sh\nkill -KILL $$\n"); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( - /^opencode export ses_test_read exited with code unknown: no stderr output$/ - ); - } - ); - - it.skipIf(skipOnWindows)( - "throws OpencodeExportError when the command answers with something that is not JSON", - async () => { - const env = installStandIn('#!/bin/sh\necho "not json"\nexit 0\n'); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( - OpencodeExportError - ); - } - ); - - it.skipIf(skipOnWindows)( - "names the parse failure when the command answers with something that is not JSON", - async () => { - const env = installStandIn('#!/bin/sh\necho "not json"\nexit 0\n'); - restorePath = env.restore; - - await expect(new OpencodeCostReaderAdapter().read(SESSION_ID)).rejects.toThrow( - /^opencode export ses_test_read did not answer with JSON: Unexpected token/ - ); - } - ); - - it.skipIf(skipOnWindows)( - "finds the binary in a later PATH entry when the first holds nothing", - async () => { - const env = installStandIn(WELL_BEHAVED_SCRIPT); - restorePath = env.restore; - const binDir = process.env.PATH ?? ""; - const emptyDir = mkdtempSync(join(tmpdir(), "aidd-opencode-first-")); - process.env.PATH = `${emptyDir}:${binDir}`; - - const { sessionFound } = await new OpencodeCostReaderAdapter().read(SESSION_ID); - - rmSync(emptyDir, { recursive: true, force: true }); - expect(sessionFound).toBe(true); - } - ); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts deleted file mode 100644 index 6c88de722..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { PersonIdentityAdapter } from "../../../../src/contexts/telemetry/infrastructure/person-identity-adapter.js"; -import { IdentityWriteError, UnreadableIdentityFileError } from "../../../../src/kernel/errors.js"; - -/** Windows reads `%APPDATA%`, never `HOME`: a sandbox that moved `HOME` alone wrote the real profile. */ -/** POSIX errno: Windows answers ENOENT where a path runs through a file. */ -const POSIX_ERRNO_ONLY = process.platform === "win32"; - -function relocateProfile(home: string): void { - process.env.HOME = home; - process.env.APPDATA = join(home, ".config"); -} - -/** On real disk: every write here goes through the file and is read back through it, since - * what this adapter stores is what decides whose records are whose. */ -describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is handed", () => { - let previousHome: string | undefined; - let previousAppData: string | undefined; - const homes: string[] = []; - - beforeEach(() => { - previousHome = process.env.HOME; - previousAppData = process.env.APPDATA; - }); - - afterEach(async () => { - if (previousHome === undefined) delete process.env.HOME; - else process.env.HOME = previousHome; - if (previousAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = previousAppData; - for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); - }); - - async function freshHome(): Promise { - const home = await mkdtemp(join(tmpdir(), "aidd-identity-adapter-")); - homes.push(home); - return home; - } - - it("removes the identity file it was constructed against", async () => { - const home = await freshHome(); - relocateProfile(home); - const adapter = new PersonIdentityAdapter(); - await adapter.mint(); - - const wasThere = await adapter.forget(adapter.filePath); - - expect(wasThere).toBe(true); - await expect(readFile(adapter.filePath, "utf8")).rejects.toThrow(); - }); - - it("is a no-op, not a failure, when the path is already gone", async () => { - const home = await freshHome(); - relocateProfile(home); - const adapter = new PersonIdentityAdapter(); - - await expect(adapter.forget(adapter.filePath)).resolves.toBe(false); - }); - - // `filePath` is frozen at construction and `forget` never asks `HOME` again, so a - // relocation between the preview and the removal cannot redirect it. - it("acts on the path it is handed, immune to HOME being relocated afterwards", async () => { - const realHome = await freshHome(); - relocateProfile(realHome); - const adapter = new PersonIdentityAdapter(); - await adapter.mint(); - const shownPath = adapter.filePath; // what a preview would have shown - - const elsewhereHome = await freshHome(); - await mkdir(join(elsewhereHome, ".config", "aidd"), { recursive: true }); - const victimPath = join(elsewhereHome, ".config", "aidd", "identity.json"); - await writeFile(victimPath, '{"person_id":"victim"}\n'); - - relocateProfile(elsewhereHome); // relocated AFTER the path was shown - - await adapter.forget(shownPath); - - await expect(readFile(shownPath, "utf8")).rejects.toThrow(); - expect(await readFile(victimPath, "utf8")).toContain("victim"); - }); -}); - -describe("PersonIdentityAdapter — what it writes, and what it reads back", () => { - let previousHome: string | undefined; - let previousAppData: string | undefined; - const homes: string[] = []; - - beforeEach(() => { - previousHome = process.env.HOME; - previousAppData = process.env.APPDATA; - }); - - afterEach(async () => { - if (previousHome === undefined) delete process.env.HOME; - else process.env.HOME = previousHome; - if (previousAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = previousAppData; - for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); - }); - - /** A throwaway profile, with the adapter constructed against it — `filePath` is frozen at - * construction, so the home has to be in place first. */ - async function adapterInFreshHome(): Promise { - const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); - homes.push(home); - relocateProfile(home); - await mkdir(join(home, ".config", "aidd"), { recursive: true }); - return new PersonIdentityAdapter(); - } - - it("reads back nothing at all before anyone has chosen", async () => { - const adapter = await adapterInFreshHome(); - - expect(await adapter.read()).toBeNull(); - expect(await adapter.readStrict()).toBeNull(); - }); - - it("mints an identifier that survives a read back through the file", async () => { - const adapter = await adapterInFreshHome(); - - const minted = await adapter.mint(); - - expect(minted.origin).toBe("minted"); - expect(minted.personId).not.toBe(""); - expect(await adapter.readStrict()).toEqual(minted); - }); - - // The distinction `origin` exists for: an identifier this machine created is not the same - // fact as one carried here from another machine. - it("records an adopted identifier as adopted, not as minted", async () => { - const adapter = await adapterInFreshHome(); - await adapter.mint(); - - const adopted = await adapter.adopt("person-from-another-machine"); - - expect(adopted).toMatchObject({ personId: "person-from-another-machine", origin: "adopted" }); - expect(await adapter.readStrict()).toEqual(adopted); - }); - - it("keeps a display name across a later write", async () => { - const adapter = await adapterInFreshHome(); - const minted = await adapter.mint(); - - await adapter.setDisplayName(minted, "Ada"); - const linked = await adapter.addAlsoMe("machine-2"); - - expect(linked.displayName).toBe("Ada"); - expect(await adapter.readStrict()).toEqual(linked); - }); - - it("adds and withdraws an added identifier, leaving the person's own untouched", async () => { - const adapter = await adapterInFreshHome(); - const minted = await adapter.mint(); - - await adapter.addAlsoMe("machine-2"); - await adapter.addAlsoMe("machine-3"); - const after = await adapter.removeAlsoMe("machine-2"); - - expect(after.personId).toBe(minted.personId); - expect(after.alsoMe).toEqual(["machine-3"]); - expect(await adapter.readStrict()).toEqual(after); - }); - - // `withAlsoMeAdded`'s rule, held on the real file: a person's own identifier is not an - // identifier added onto them. - it("refuses to list the person's own identifier among the ones added onto them", async () => { - const adapter = await adapterInFreshHome(); - const minted = await adapter.mint(); - - const after = await adapter.addAlsoMe(minted.personId); - - expect(after.alsoMe).toEqual([]); - }); - - // `read` is for every consumer that must not fail over one damaged file; `readStrict` for - // the one caller that has to tell "nobody chose" apart from "could not be read". - it("reads a damaged file as nothing, and refuses it strictly", async () => { - const adapter = await adapterInFreshHome(); - await writeFile(adapter.filePath, "{ not json"); - - expect(await adapter.read()).toBeNull(); - await expect(adapter.readStrict()).rejects.toThrow(/identity/iu); - }); - - it("refuses to add an identifier when nobody has chosen one to add it onto", async () => { - const adapter = await adapterInFreshHome(); - - await expect(adapter.addAlsoMe("machine-2")).rejects.toThrow(); - }); - - it("writes a file a person can open and correct by hand", async () => { - const adapter = await adapterInFreshHome(); - const minted = await adapter.mint(); - - const raw = await readFile(adapter.filePath, "utf8"); - - expect(JSON.parse(raw)).toMatchObject({ person_id: minted.personId, origin: "minted" }); - expect(raw.endsWith("\n")).toBe(true); - }); - - it("writes the quietest shape: no also_me key until an identifier is added", async () => { - const adapter = await adapterInFreshHome(); - const minted = await adapter.mint(); - - expect(JSON.parse(await readFile(adapter.filePath, "utf8"))).toStrictEqual({ - person_id: minted.personId, - origin: "minted", - }); - }); - - it.skipIf(process.platform === "win32")( - "writes a file readable by this person alone", - async () => { - const adapter = await adapterInFreshHome(); - - await adapter.mint(); - - expect(((await stat(adapter.filePath)).mode & 0o777).toString(8)).toBe("600"); - } - ); - - it("reads an empty person_id as nobody having chosen", async () => { - const adapter = await adapterInFreshHome(); - await writeFile(adapter.filePath, '{"person_id":""}\n'); - - expect(await adapter.read()).toBeNull(); - expect(await adapter.readStrict()).toBeNull(); - }); - - it("reads a person_id that is not a string as nobody having chosen", async () => { - const adapter = await adapterInFreshHome(); - await writeFile(adapter.filePath, '{"person_id":42}\n'); - - expect(await adapter.readStrict()).toBeNull(); - }); - - it("keeps only the strings among the identifiers added onto a person", async () => { - const adapter = await adapterInFreshHome(); - await writeFile(adapter.filePath, '{"person_id":"p-1","also_me":["machine-2",3,null]}\n'); - - expect(await adapter.readStrict()).toStrictEqual({ - personId: "p-1", - origin: "minted", - alsoMe: ["machine-2"], - }); - }); - - it("reads an empty display name as none at all", async () => { - const adapter = await adapterInFreshHome(); - await writeFile(adapter.filePath, '{"person_id":"p-1","display_name":""}\n'); - - expect(await adapter.readStrict()).toStrictEqual({ - personId: "p-1", - origin: "minted", - alsoMe: [], - }); - }); - - it("reads a display name that is not a string as none at all", async () => { - const adapter = await adapterInFreshHome(); - await writeFile(adapter.filePath, '{"person_id":"p-1","display_name":7}\n'); - - expect(await adapter.readStrict()).toStrictEqual({ - personId: "p-1", - origin: "minted", - alsoMe: [], - }); - }); - - it("says what it was asked to add onto when no identity exists", async () => { - const adapter = await adapterInFreshHome(); - - await expect(adapter.addAlsoMe("machine-2")).rejects.toThrow( - `Could not write the identity file at ${adapter.filePath} (no identity exists to add an identifier onto).` - ); - }); - - it("says what it was asked to remove from when no identity exists", async () => { - const adapter = await adapterInFreshHome(); - - await expect(adapter.removeAlsoMe("machine-2")).rejects.toThrow( - `Could not write the identity file at ${adapter.filePath} (no identity exists to remove an identifier onto).` - ); - }); - - it("refuses strictly a file that is there but cannot be read as a file", async () => { - const adapter = await adapterInFreshHome(); - await mkdir(adapter.filePath); - - const strict = adapter.readStrict(); - - await expect(strict).rejects.toBeInstanceOf(UnreadableIdentityFileError); - await expect(strict).rejects.toThrow( - `Could not read the identity file at ${adapter.filePath} (EISDIR` - ); - }); - it.skipIf(POSIX_ERRNO_ONLY)( - "reports a write that could not go out, naming the file", - async () => { - const home = await mkdtemp(join(tmpdir(), "aidd-identity-rw-")); - homes.push(home); - relocateProfile(home); - await writeFile(join(home, ".config"), ""); - const adapter = new PersonIdentityAdapter(); - - const minted = adapter.mint(); - - await expect(minted).rejects.toBeInstanceOf(IdentityWriteError); - await expect(minted).rejects.toThrow( - `Could not write the identity file at ${adapter.filePath} (ENOTDIR` - ); - } - ); -}); - -describe("PersonIdentityAdapter.forget — what it removes and what it reports", () => { - let previousHome: string | undefined; - let previousAppData: string | undefined; - const homes: string[] = []; - - beforeEach(() => { - previousHome = process.env.HOME; - previousAppData = process.env.APPDATA; - }); - - afterEach(async () => { - if (previousHome === undefined) delete process.env.HOME; - else process.env.HOME = previousHome; - if (previousAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = previousAppData; - for (const home of homes.splice(0)) await rm(home, { recursive: true, force: true }); - }); - - async function adapterInFreshHome(): Promise { - const home = await mkdtemp(join(tmpdir(), "aidd-identity-forget-")); - homes.push(home); - relocateProfile(home); - await mkdir(join(home, ".config", "aidd"), { recursive: true }); - return new PersonIdentityAdapter(); - } - - it("removes a damaged identity that is a directory, not a file", async () => { - const adapter = await adapterInFreshHome(); - await mkdir(adapter.filePath); - await writeFile(join(adapter.filePath, "stray"), ""); - - expect(await adapter.forget(adapter.filePath)).toBe(true); - await expect(readFile(adapter.filePath, "utf8")).rejects.toThrow(); - }); - it.skipIf(POSIX_ERRNO_ONLY)( - "reports a removal that failed for a reason other than being gone, as a removal", - async () => { - const adapter = await adapterInFreshHome(); - await adapter.mint(); - const unreachable = join(adapter.filePath, "child"); - - const forgotten = adapter.forget(unreachable); - - await expect(forgotten).rejects.toBeInstanceOf(IdentityWriteError); - await expect(forgotten).rejects.toThrow( - `Could not remove the identity file at ${unreachable} (ENOTDIR` - ); - } - ); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/person-identity-location.unit.test.ts b/cli/tests/contexts/telemetry/infrastructure/person-identity-location.unit.test.ts deleted file mode 100644 index d099b4d01..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/person-identity-location.unit.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { PersonIdentityAdapter } from "../../../../src/contexts/telemetry/infrastructure/person-identity-adapter.js"; - -/** `identityDir()` is private to the adapter, so this drives it through `filePath` on every - * platform. An identity reached through a location a team or a CI points at is not one's own. */ -function withPlatform(platform: NodeJS.Platform, run: () => T): T { - const original = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { value: platform, configurable: true }); - try { - return run(); - } finally { - if (original) Object.defineProperty(process, "platform", original); - } -} - -const previousAppData = process.env.APPDATA; -const previousHome = process.env.HOME; -const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; -const temporaryHomes: string[] = []; - -function freshHome(): string { - const home = mkdtempSync(join(tmpdir(), "aidd-identity-location-")); - temporaryHomes.push(home); - process.env.HOME = home; - return home; -} - -afterEach(() => { - if (previousAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = previousAppData; - if (previousHome === undefined) delete process.env.HOME; - else process.env.HOME = previousHome; - if (previousUserConfigDir === undefined) delete process.env.AIDD_USER_CONFIG_DIR; - else process.env.AIDD_USER_CONFIG_DIR = previousUserConfigDir; - for (const home of temporaryHomes.splice(0)) rmSync(home, { recursive: true, force: true }); -}); - -function filePath(): string { - return new PersonIdentityAdapter().filePath; -} - -describe("where the identity file lands", () => { - it("a POSIX machine keeps it under the OS user's own .config", () => { - const home = freshHome(); - - expect(withPlatform("linux", filePath)).toBe(join(home, ".config", "aidd", "identity.json")); - }); - - it("a Windows machine keeps it under %APPDATA%, never under .config", () => { - freshHome(); - process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); - - expect(withPlatform("win32", filePath)).toBe( - join(process.env.APPDATA, "aidd", "identity.json") - ); - }); - - it("Windows without APPDATA falls back rather than inventing a path", () => { - const home = freshHome(); - delete process.env.APPDATA; - - expect(withPlatform("win32", filePath)).toBe(join(home, ".config", "aidd", "identity.json")); - }); - - it("AIDD_USER_CONFIG_DIR never moves it, on either platform", () => { - const home = freshHome(); - process.env.AIDD_USER_CONFIG_DIR = join(tmpdir(), "a-repository-or-a-ci-picked-this"); - - expect(withPlatform("linux", filePath)).toBe(join(home, ".config", "aidd", "identity.json")); - - process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); - expect(withPlatform("win32", filePath)).toBe( - join(process.env.APPDATA, "aidd", "identity.json") - ); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts deleted file mode 100644 index 1c93d46a5..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { RunJournalReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; -import { environmentWithoutGitVariables } from "../../../../src/runtime/git/git-environment.js"; -import { journalFileWrites } from "../../../helpers/telemetry-journal-hook.js"; - -// The line task derivation rests on, exercised against the hook that writes it and the adapter -// that reads it with nothing stubbed between: a change to either side empties every task. -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; -const SESSION_ID = "22222222-2222-4222-8222-222222222222"; -const TASK_FILE = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"; - -describe("file_written, from the hook that writes it to the reader that reads it", () => { - let projectRoot: string; - let runsDir: string; - - beforeEach(async () => { - // realpath because git resolves symlinks in --show-toplevel and macOS puts tmpdir - // behind one; the hook compares the two and would otherwise reject every path. - projectRoot = realpathSync(await mkdtemp(join(tmpdir(), "aidd-file-written-"))); - execFileSync("git", ["init", "-q", projectRoot], { - env: environmentWithoutGitVariables(process.env), - }); - runsDir = join(projectRoot, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await mkdir(join(projectRoot, "aidd_docs", "tasks", "2026_08", "2026_08_21_cost-reporter"), { - recursive: true, - }); - await writeFile(join(projectRoot, TASK_FILE), "# plan\n"); - await writeFile(join(projectRoot, ".aidd-placeholder"), ""); - await mkdir(join(projectRoot, ".aidd"), { recursive: true }); - await writeFile( - join(projectRoot, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }) - ); - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - `${JSON.stringify({ - type: "session_start", - at: "2026-08-21T09:00:00Z", - run_id: RUN_ID, - tool: "claude-code", - vendor_id: SESSION_ID, - })}\n` - ); - }); - - afterEach(async () => { - await rm(projectRoot, { recursive: true, force: true }); - }); - - function writePayload(): Record { - return { - tool_name: "Write", - tool_input: { file_path: join(projectRoot, TASK_FILE) }, - cwd: projectRoot, - // Deliberately not the id the run file is named with — the hook must use the id it - // was handed, read behind the host's own declaration, not this spelling. - session_id: "a-different-id", - }; - } - - it("appends a repository-relative path the reader surfaces as written", async () => { - journalFileWrites.handleFileWritten(writePayload(), "claude-code", SESSION_ID); - - const journal = await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); - - expect(journal?.filesWritten.map((written) => written.path)).toEqual([TASK_FILE]); - }); - - it("uses the session id it is handed, not the payload's own spelling", async () => { - // With the payload's spelling there is no run file, so nothing would be written at all. - journalFileWrites.handleFileWritten(writePayload(), "claude-code", SESSION_ID); - - const content = await readFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), "utf8"); - - expect(content).toContain('"file_written"'); - expect(content).not.toContain("a-different-id"); - }); - - it("records nothing for a write outside any task folder", async () => { - const payload = writePayload(); - payload.tool_input = { file_path: join(projectRoot, "cli", "src", "index.ts") }; - - journalFileWrites.handleFileWritten(payload, "claude-code", SESSION_ID); - - expect((await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID))?.filesWritten).toEqual( - [] - ); - }); - - it("covers Claude Code alone, which a report has to print as a limit rather than assume away", () => { - // Copilot and Cursor were never captured writing a readable path, and Codex's writes live - // inside an apply_patch command string; a host absent here is never attributable to a task. - expect(Object.keys(journalFileWrites.WRITTEN_PATH_EXTRACTOR_BY_HOST)).toEqual(["claude-code"]); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts deleted file mode 100644 index b8e7af05c..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - READABLE_JOURNAL_SCHEMA_VERSION, - RunJournalReaderAdapter, - sanitizePathSegment, -} from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; -import { journalRecord, journalRepo } from "../../../helpers/telemetry-journal-hook.js"; - -// A real-shaped ULID (26 Crockford-base32 characters): the adapter splits a run file's name -// on that fixed length, never on "__", so the id itself must be genuine. -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; -const SESSION_ID = "22222222-2222-4222-8222-222222222222"; - -function runFileLines(...lines: readonly unknown[]): string { - return `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`; -} - -describe("RunJournalReaderAdapter", () => { - let projectRoot: string; - let runsDir: string; - - beforeEach(async () => { - projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-")); - runsDir = join(projectRoot, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(projectRoot, { recursive: true, force: true }); - delete process.env.AIDD_RUNS_DIR; - }); - - it("reads a session's step_start and turn_end lines, in file order, skipping every other type", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines( - { type: "session_start", at: "2026-08-20T09:59:00Z", run_id: RUN_ID }, - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-dev:02-implement" }, - { type: "file_written", at: "2026-08-20T10:01:00Z", path: "some/task/file.md" }, - { type: "turn_end", at: "2026-08-20T10:05:00Z" } - ) - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journal = await adapter.read(SESSION_ID); - - expect(journal?.boundaries).toEqual([ - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-dev:02-implement" }, - { type: "turn_end", at: "2026-08-20T10:05:00Z" }, - ]); - }); - - it("reads an empty worktree id and repo id as not stated, never as a worktree named nothing", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines({ - type: "session_start", - at: "2026-08-20T09:59:00Z", - run_id: RUN_ID, - tool: "claude-code", - vendor_id: SESSION_ID, - worktree_id: "", - worktree_repo_id: "", - }) - ); - - const journal = await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); - - expect(journal?.session).toStrictEqual({ - type: "session_start", - at: "2026-08-20T09:59:00Z", - run_id: RUN_ID, - tool: "claude-code", - vendor_id: SESSION_ID, - }); - }); - - it("keeps a stated worktree id and repo id as written", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines({ - type: "session_start", - at: "2026-08-20T09:59:00Z", - run_id: RUN_ID, - tool: "claude-code", - vendor_id: SESSION_ID, - worktree_id: "feature-x", - worktree_repo_id: "framework", - }) - ); - - const journal = await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); - - expect(journal?.session).toStrictEqual({ - type: "session_start", - at: "2026-08-20T09:59:00Z", - run_id: RUN_ID, - tool: "claude-code", - vendor_id: SESSION_ID, - worktree_id: "feature-x", - worktree_repo_id: "framework", - }); - }); - - it("answers null for a session no run file names, rather than the wrong file", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__other-session.jsonl`), - runFileLines({ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "x" }) - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - await expect(adapter.read(SESSION_ID)).resolves.toBeNull(); - }); - - it("answers null, not an error, when aidd_docs/runs does not exist at all", async () => { - await rm(runsDir, { recursive: true, force: true }); - const adapter = new RunJournalReaderAdapter(projectRoot); - - await expect(adapter.read(SESSION_ID)).resolves.toBeNull(); - }); - - it("skips a truncated final line rather than failing the whole read", async () => { - const goodLine = JSON.stringify({ - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "aidd-dev:02-implement", - }); - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - `${goodLine}\n{"type":"turn_end","at":"2026-08-20T10:05` - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journal = await adapter.read(SESSION_ID); - - expect(journal?.boundaries).toEqual([ - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-dev:02-implement" }, - ]); - }); - - // The writing hook anchors at the repository root, so a reader anchored at the working - // directory answers "this session declared no task" — a claim about the work, not the read. - it("anchors at the repository root, so a subdirectory finds the journal the hook wrote", async () => { - await mkdir(join(projectRoot, ".git"), { recursive: true }); - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines({ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-the-root" }) - ); - const subdirectory = join(projectRoot, "cli", "nested"); - await mkdir(subdirectory, { recursive: true }); - - const journal = await new RunJournalReaderAdapter(subdirectory).read(SESSION_ID); - - expect(journal?.boundaries).toEqual([ - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-the-root" }, - ]); - }); - - // A linked worktree's `.git` is a FILE holding `gitdir: …`, not a directory, so accepting - // only a directory leaves every worktree anchored at the process working directory. - it("accepts a linked worktree, whose .git is a file rather than a directory", async () => { - await writeFile(join(projectRoot, ".git"), "gitdir: /elsewhere/.git/worktrees/w\n"); - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines({ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-the-worktree" }) - ); - const subdirectory = join(projectRoot, "cli"); - await mkdir(subdirectory, { recursive: true }); - - const journal = await new RunJournalReaderAdapter(subdirectory).read(SESSION_ID); - - expect(journal?.boundaries).toEqual([ - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-the-worktree" }, - ]); - }); - - // Outside any checkout there is no root to walk up to. The reader keeps the directory it - // was handed rather than climbing to the filesystem root and reading a stranger's journal. - it("keeps the directory it was given when no repository root is above it", async () => { - const outside = await mkdtemp(join(tmpdir(), "aidd-run-journal-outside-")); - await mkdir(join(outside, "aidd_docs", "runs"), { recursive: true }); - await writeFile( - join(outside, "aidd_docs", "runs", `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines({ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-outside" }) - ); - - const journal = await new RunJournalReaderAdapter(outside).read(SESSION_ID); - - expect(journal?.boundaries).toEqual([ - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-outside" }, - ]); - await rm(outside, { recursive: true, force: true }); - }); - - it("honors AIDD_RUNS_DIR over /aidd_docs/runs, matching the writing hook", async () => { - const overrideDir = await mkdtemp(join(tmpdir(), "aidd-run-journal-override-")); - process.env.AIDD_RUNS_DIR = overrideDir; - await writeFile( - join(overrideDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines({ type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-override" }) - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journal = await adapter.read(SESSION_ID); - - expect(journal?.boundaries).toEqual([ - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "from-override" }, - ]); - await rm(overrideDir, { recursive: true, force: true }); - }); -}); - -// Duplicated on purpose, not shared at runtime: this keeps the copy honest, so the hook's -// regex moving turns this red rather than leaving a session id silently unmatched. -describe("sanitizePathSegment — agrees with the journal hook's own function", () => { - it.each([ - "22222222-2222-4222-8222-222222222222", - "has spaces", - "weird/../chars?", - "", - ".", - "..", - "already__contains-a-double-underscore", - ])("matches for %s", (segment) => { - expect(sanitizePathSegment(segment)).toBe(journalRepo.sanitizePathSegment(segment)); - }); -}); - -describe("RunJournalReaderAdapter, beyond the boundaries", () => { - let projectRoot: string; - let runsDir: string; - - beforeEach(async () => { - projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-more-")); - runsDir = join(projectRoot, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(projectRoot, { recursive: true, force: true }); - delete process.env.AIDD_RUNS_DIR; - }); - - const HEADER = { - type: "session_start", - at: "2026-08-20T09:59:00Z", - schema_version: 2, - run_id: RUN_ID, - project_id: "acme-widgets", - project_remote: "github.com/acme/widgets", - tool: "claude-code", - vendor_id: SESSION_ID, - vendor_field: "session.id", - }; - - it("reads the header line, so a report knows which tool and project a session was", async () => { - await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), runFileLines(HEADER)); - const adapter = new RunJournalReaderAdapter(projectRoot); - - expect((await adapter.read(SESSION_ID))?.session).toEqual({ - type: "session_start", - at: "2026-08-20T09:59:00Z", - schema_version: 2, - run_id: RUN_ID, - project_id: "acme-widgets", - project_remote: "github.com/acme/widgets", - tool: "claude-code", - vendor_id: SESSION_ID, - }); - }); - - it("reads plugin_version off the header when the hook stamped one", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines({ ...HEADER, plugin_version: "0.1.0" }) - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - expect((await adapter.read(SESSION_ID))?.session?.plugin_version).toBe("0.1.0"); - }); - - it("reads no plugin_version at all for a line written before this field existed - unknown, never a guessed default", async () => { - await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), runFileLines(HEADER)); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const session = (await adapter.read(SESSION_ID))?.session; - expect(session?.plugin_version).toBeUndefined(); - expect(Object.hasOwn(session ?? {}, "plugin_version")).toBe(false); - }); - - it("reads the written paths as paths, deriving no task from them", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines( - HEADER, - { - type: "file_written", - at: "2026-08-20T10:01:00Z", - path: "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md", - }, - { type: "file_written", at: "2026-08-20T10:02:00Z", path: "cli/src/index.ts" } - ) - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journal = await adapter.read(SESSION_ID); - - expect(journal?.filesWritten).toEqual([ - { - type: "file_written", - at: "2026-08-20T10:01:00Z", - path: "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md", - }, - { type: "file_written", at: "2026-08-20T10:02:00Z", path: "cli/src/index.ts" }, - ]); - expect(JSON.stringify(journal)).not.toContain("task_id"); - }); - - it("keeps a session's boundaries when its header line is torn", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - `{"type":"session_start","at":"2026-08-2\n${runFileLines({ type: "turn_end", at: "2026-08-20T10:05:00Z" })}` - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journal = await adapter.read(SESSION_ID); - - expect(journal?.session).toBeUndefined(); - expect(journal?.boundaries).toEqual([{ type: "turn_end", at: "2026-08-20T10:05:00Z" }]); - }); - - it("refuses a header missing a field a join needs, rather than surfacing half of one", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - runFileLines({ type: "session_start", at: "2026-08-20T09:59:00Z", run_id: RUN_ID }) - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - expect((await adapter.read(SESSION_ID))?.session).toBeUndefined(); - }); - - it("lists every session it holds, for a caller with no identifier to ask about", async () => { - const otherSession = "33333333-3333-4333-8333-333333333333"; - const otherRunId = "01ARZ3NDEKTSV4RRFFQ69G5FBW"; - await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), runFileLines(HEADER)); - await writeFile( - join(runsDir, `${otherRunId}__${otherSession}.jsonl`), - runFileLines({ ...HEADER, run_id: otherRunId, tool: "codex", vendor_id: otherSession }) - ); - await writeFile(join(runsDir, "README.md"), "not a run file\n"); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journals = await adapter.list(); - - expect(journals.map((journal) => journal.session?.vendor_id)).toEqual([ - SESSION_ID, - otherSession, - ]); - expect(journals.map((journal) => journal.session?.tool)).toEqual(["claude-code", "codex"]); - }); - - it("lists nothing, rather than throwing, when no runs directory exists", async () => { - await rm(runsDir, { recursive: true, force: true }); - const adapter = new RunJournalReaderAdapter(projectRoot); - - expect(await adapter.list()).toEqual([]); - }); - - it("honours AIDD_RUNS_DIR when listing, exactly as when reading one session", async () => { - const elsewhere = await mkdtemp(join(tmpdir(), "aidd-runs-elsewhere-")); - await writeFile(join(elsewhere, `${RUN_ID}__${SESSION_ID}.jsonl`), runFileLines(HEADER)); - process.env.AIDD_RUNS_DIR = elsewhere; - const adapter = new RunJournalReaderAdapter(projectRoot); - - expect((await adapter.list()).map((journal) => journal.session?.vendor_id)).toEqual([ - SESSION_ID, - ]); - - await rm(elsewhere, { recursive: true, force: true }); - }); -}); - -describe("RunJournalReaderAdapter.deleteRunFile — confined to the directory it is handed", () => { - let projectRoot: string; - let runsDir: string; - - beforeEach(async () => { - projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-delete-")); - runsDir = join(projectRoot, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(projectRoot, { recursive: true, force: true }); - }); - - it("removes a run file by name, from the directory it is handed", async () => { - await writeFile(join(runsDir, "a.jsonl"), "content\n"); - const adapter = new RunJournalReaderAdapter(projectRoot); - - await adapter.deleteRunFile(runsDir, "a.jsonl"); - - expect(await adapter.listRunFiles()).toEqual([]); - }); - - it("is a no-op, not a failure, when the name is already gone", async () => { - const adapter = new RunJournalReaderAdapter(projectRoot); - await expect(adapter.deleteRunFile(runsDir, "never-existed.jsonl")).resolves.toBeUndefined(); - }); - - // `join` normalises `..` away visually but still deletes wherever the normalised path - // lands, so confinement must be a property of this method, not of who calls it. - it("refuses a relative walk out of the directory it is handed, rather than deleting outside it", async () => { - // aidd_docs/runs -> .. -> aidd_docs -> .. -> projectRoot: "../../VICTIM.txt" lands here. - const victimPath = join(projectRoot, "VICTIM.txt"); - await writeFile(victimPath, "do not delete me\n"); - const adapter = new RunJournalReaderAdapter(projectRoot); - - await expect(adapter.deleteRunFile(runsDir, "../../VICTIM.txt")).rejects.toThrow(); - - expect(await readFile(victimPath, "utf8")).toBe("do not delete me\n"); - }); - - it("refuses a bare '..' or '.' rather than acting on the directory itself", async () => { - const adapter = new RunJournalReaderAdapter(projectRoot); - await expect(adapter.deleteRunFile(runsDir, "..")).rejects.toThrow(); - await expect(adapter.deleteRunFile(runsDir, ".")).rejects.toThrow(); - }); - - // `runsDir` is frozen at construction and `deleteRunFile` takes `dir` explicitly, so - // `AIDD_RUNS_DIR` moving between the preview and the removal cannot redirect it. - it("acts on the dir it is handed, immune to AIDD_RUNS_DIR being relocated afterwards", async () => { - await writeFile(join(runsDir, "shown.jsonl"), "shown\n"); - const adapter = new RunJournalReaderAdapter(projectRoot); - const shownDir = adapter.runsDir; // what a preview would have shown - - const elsewhere = await mkdtemp(join(tmpdir(), "aidd-runs-relocated-")); - await writeFile(join(elsewhere, "shown.jsonl"), "victim\n"); - process.env.AIDD_RUNS_DIR = elsewhere; // relocated AFTER the dir was shown - - await adapter.deleteRunFile(shownDir, "shown.jsonl"); - - await expect(readFile(join(shownDir, "shown.jsonl"), "utf8")).rejects.toThrow(); - expect(await readFile(join(elsewhere, "shown.jsonl"), "utf8")).toBe("victim\n"); - - delete process.env.AIDD_RUNS_DIR; - await rm(elsewhere, { recursive: true, force: true }); - }); -}); - -/** The hook stamps `schema_version` on every `session_start`: a journal written under a - * schema whose line shapes changed must be refused, not read as if it were this one. */ -describe("RunJournalReaderAdapter — the schema a journal states it was written under", () => { - let projectRoot: string; - let runsDir: string; - - beforeEach(async () => { - projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-schema-")); - runsDir = join(projectRoot, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(projectRoot, { recursive: true, force: true }); - }); - - function header(extra: Record): Record { - return { - type: "session_start", - at: "2026-08-20T09:59:00Z", - run_id: RUN_ID, - tool: "claude-code", - vendor_id: SESSION_ID, - ...extra, - }; - } - - async function writeJournal(...lines: readonly unknown[]): Promise { - await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), runFileLines(...lines)); - } - - // Reached rather than copied: a reader whose own constant is a second copy of the writer's - // goes on claiming it can read a schema the writer has already moved past. - it("reads exactly the schema the hook writes", () => { - expect(READABLE_JOURNAL_SCHEMA_VERSION).toBe(journalRecord.SCHEMA_VERSION); - }); - - it("carries the stated schema through onto the session it read", async () => { - await writeJournal(header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION })); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journal = await adapter.read(SESSION_ID); - - expect(journal?.session?.schema_version).toBe(READABLE_JOURNAL_SCHEMA_VERSION); - }); - - // The point of a version field: a key added under the schema this reader knows is a key it - // may ignore, never one that costs it the journal. - it("reads a journal carrying a key it has never heard of, under a schema it knows", async () => { - await writeJournal( - header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION, a_key_from_later: "ignored" }), - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-dev:02-implement" } - ); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journal = await adapter.read(SESSION_ID); - - expect(journal?.session?.vendor_id).toBe(SESSION_ID); - expect(journal?.boundaries).toHaveLength(1); - }); - - it("refuses a journal written under a schema newer than the one it reads", async () => { - await writeJournal(header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION + 1 }), { - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "aidd-dev:02-implement", - }); - const adapter = new RunJournalReaderAdapter(projectRoot); - - expect(await adapter.read(SESSION_ID)).toBeNull(); - expect(await adapter.list()).toEqual([]); - }); - - it("refuses one written under the schema this log replaced, whose lines are another shape", async () => { - await writeJournal(header({ schema_version: 1 })); - const adapter = new RunJournalReaderAdapter(projectRoot); - - expect(await adapter.read(SESSION_ID)).toBeNull(); - }); - - // Absence is not a stated disagreement: every journal written before this field existed was - // read without it, and refusing them now would drop attribution this reader can still give. - it("still reads a journal that states no schema at all", async () => { - await writeJournal(header({}), { - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "aidd-dev:02-implement", - }); - const adapter = new RunJournalReaderAdapter(projectRoot); - - const journal = await adapter.read(SESSION_ID); - - expect(journal?.session?.schema_version).toBeUndefined(); - expect(journal?.boundaries).toHaveLength(1); - }); - - // Dropped silently, the diagnostic reads "none carry a readable session_start" about a file - // whose header it read perfectly well, blaming a torn write for a version disagreement. - it("still names the schema of every journal it refused", async () => { - await writeJournal(header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION + 1 })); - const adapter = new RunJournalReaderAdapter(projectRoot); - - expect(await adapter.listForeignSchemas()).toEqual([READABLE_JOURNAL_SCHEMA_VERSION + 1]); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts deleted file mode 100644 index f56eac3c1..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-lines.integration.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - READABLE_JOURNAL_SCHEMA_VERSION, - RunJournalReaderAdapter, -} from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; - -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; -const SESSION_ID = "22222222-2222-4222-8222-222222222222"; -const AT = "2026-08-20T10:00:00Z"; - -const HEADER = { - type: "session_start", - at: "2026-08-20T09:59:00Z", - run_id: RUN_ID, - tool: "claude-code", - vendor_id: SESSION_ID, -} as const; - -function lines(...values: readonly unknown[]): string { - return `${values.map((value) => JSON.stringify(value)).join("\n")}\n`; -} - -describe("RunJournalReaderAdapter, one line at a time", () => { - let projectRoot: string; - let runsDir: string; - - beforeEach(async () => { - projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-lines-")); - runsDir = join(projectRoot, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - }); - - afterEach(async () => { - await rm(projectRoot, { recursive: true, force: true }); - }); - - async function readAfterWriting(...values: readonly unknown[]) { - await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), lines(...values)); - return new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); - } - - async function readRaw(content: string) { - await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), content); - return new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); - } - - describe("the run file a session id names", () => { - it("ignores a file whose name carries the session id under another extension", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonx`), - lines({ type: "step_start", at: AT, skill: "x" }) - ); - - await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); - }); - - it("ignores a run file naming no session at all", async () => { - await writeFile(join(runsDir, `${RUN_ID}__.jsonl`), lines({ type: "turn_end", at: AT })); - - await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); - }); - - it("ignores a file whose run id and session id are not joined by the double underscore", async () => { - await writeFile( - join(runsDir, `${RUN_ID}--${SESSION_ID}.jsonl`), - lines({ type: "turn_end", at: AT }) - ); - - await expect(new RunJournalReaderAdapter(projectRoot).read(SESSION_ID)).resolves.toBeNull(); - }); - }); - - describe("a boundary line", () => { - it("reads a step_end as the boundary that names its skill", async () => { - const journal = await readAfterWriting({ - type: "step_end", - at: AT, - skill: "aidd-dev:01-plan", - }); - - expect(journal?.boundaries).toStrictEqual([ - { type: "step_end", at: AT, skill: "aidd-dev:01-plan" }, - ]); - }); - - it("carries a step_start's turn_id when the hook wrote one", async () => { - const journal = await readAfterWriting({ - type: "step_start", - at: AT, - skill: "aidd-dev:01-plan", - turn_id: "turn-7", - }); - - expect(journal?.boundaries).toStrictEqual([ - { type: "step_start", at: AT, skill: "aidd-dev:01-plan", turn_id: "turn-7" }, - ]); - }); - - it("carries no turn_id key at all for a step_start written without one", async () => { - const journal = await readAfterWriting({ - type: "step_start", - at: AT, - skill: "aidd-dev:01-plan", - }); - - expect(journal?.boundaries).toStrictEqual([ - { type: "step_start", at: AT, skill: "aidd-dev:01-plan" }, - ]); - }); - - it("drops a turn_end that carries no moment", async () => { - const journal = await readAfterWriting({ type: "turn_end" }); - - expect(journal?.boundaries).toStrictEqual([]); - }); - - it("drops a step_start that names no skill", async () => { - const journal = await readAfterWriting({ type: "step_start", at: AT }); - - expect(journal?.boundaries).toStrictEqual([]); - }); - - it("drops a moment that is not a string", async () => { - const journal = await readAfterWriting({ type: "turn_end", at: 1755684000000 }); - - expect(journal?.boundaries).toStrictEqual([]); - }); - - it("reads a line of a type it has never heard of as nothing, even carrying a moment and a skill", async () => { - const journal = await readAfterWriting({ - type: "step_paused", - at: AT, - skill: "aidd-dev:01-plan", - }); - - expect(journal).toStrictEqual({ boundaries: [], filesWritten: [], taskDeclarations: [] }); - }); - }); - - describe("a file_written or task_declared line", () => { - it.each([ - ["file_written", { type: "file_written", path: "aidd_docs/tasks/2026_08/t/plan.md" }], - ["file_written", { type: "file_written", at: AT }], - ["task_declared", { type: "task_declared", path: "aidd_docs/tasks/2026_08/t/plan.md" }], - ["task_declared", { type: "task_declared", at: AT }], - ])("drops a %s line missing its moment or its path: %j", async (_type, line) => { - const journal = await readAfterWriting(line); - - expect(journal).toStrictEqual({ boundaries: [], filesWritten: [], taskDeclarations: [] }); - }); - - it("reads a task_declared line as exactly its moment and its path", async () => { - const journal = await readAfterWriting({ - type: "task_declared", - at: AT, - path: "aidd_docs/tasks/2026_08/t/spec.md", - source: "stated", - }); - - expect(journal?.taskDeclarations).toStrictEqual([ - { type: "task_declared", at: AT, path: "aidd_docs/tasks/2026_08/t/spec.md" }, - ]); - }); - }); - - describe("the header line", () => { - it("reads a minimal header as exactly its five fields, adding no key for a field nobody wrote", async () => { - const journal = await readAfterWriting(HEADER); - - expect(journal?.session).toStrictEqual(HEADER); - }); - - it.each(["at", "run_id", "tool", "vendor_id"])( - "refuses a header missing %s alone", - async (field) => { - const partial = Object.fromEntries(Object.entries(HEADER).filter(([key]) => key !== field)); - const journal = await readAfterWriting(partial); - - expect(journal?.session).toBeUndefined(); - } - ); - - it("refuses a header whose run_id is not a string", async () => { - const journal = await readAfterWriting({ ...HEADER, run_id: 42 }); - - expect(journal?.session).toBeUndefined(); - }); - - it("takes the header only from a session_start line", async () => { - const journal = await readAfterWriting({ ...HEADER, type: "session_end" }); - - expect(journal?.session).toBeUndefined(); - }); - - it("carries both worktree fields when the hook wrote them", async () => { - const journal = await readAfterWriting({ - ...HEADER, - worktree_id: "feature-x", - worktree_repo_id: "acme/widgets", - }); - - expect(journal?.session).toStrictEqual({ - ...HEADER, - worktree_id: "feature-x", - worktree_repo_id: "acme/widgets", - }); - }); - - it("carries project_id, project_remote and schema_version each on its own", async () => { - const journal = await readAfterWriting({ - ...HEADER, - schema_version: READABLE_JOURNAL_SCHEMA_VERSION, - project_id: "widgets", - project_remote: "github.com/acme/widgets", - }); - - expect(journal?.session).toStrictEqual({ - ...HEADER, - schema_version: READABLE_JOURNAL_SCHEMA_VERSION, - project_id: "widgets", - project_remote: "github.com/acme/widgets", - }); - }); - - it("reads a schema_version that is not a number as none stated, and still reads the journal", async () => { - const journal = await readAfterWriting( - { ...HEADER, schema_version: String(READABLE_JOURNAL_SCHEMA_VERSION) }, - { type: "turn_end", at: AT } - ); - - expect(journal).toStrictEqual({ - boundaries: [{ type: "turn_end", at: AT }], - filesWritten: [], - taskDeclarations: [], - session: HEADER, - }); - }); - - it("reads a schema_version that overflowed to a non-finite number as none stated", async () => { - const journal = await readRaw( - `${JSON.stringify(HEADER).slice(0, -1)},"schema_version":1e999}\n` - ); - - expect(journal?.session).toStrictEqual(HEADER); - }); - }); - - describe("the run files it lists", () => { - it("lists only .jsonl names, sorted", async () => { - await writeFile(join(runsDir, "README.md"), "not a run file\n"); - await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), lines(HEADER)); - - await expect(new RunJournalReaderAdapter(projectRoot).listRunFiles()).resolves.toStrictEqual([ - `${RUN_ID}__${SESSION_ID}.jsonl`, - ]); - }); - - it("lists no run file at all, rather than throwing, when the runs directory is missing", async () => { - await rm(runsDir, { recursive: true, force: true }); - - await expect(new RunJournalReaderAdapter(projectRoot).listRunFiles()).resolves.toStrictEqual( - [] - ); - }); - - it("names no foreign schema for a journal under its own schema, nor for one stating none", async () => { - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - lines({ ...HEADER, schema_version: READABLE_JOURNAL_SCHEMA_VERSION }) - ); - await writeFile(join(runsDir, `${RUN_ID}__other.jsonl`), lines(HEADER)); - - await expect( - new RunJournalReaderAdapter(projectRoot).listForeignSchemas() - ).resolves.toStrictEqual([]); - }); - - it("still names a foreign schema beside a run file whose header is torn", async () => { - await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), '{"type":"session_st\n'); - await writeFile( - join(runsDir, `${RUN_ID}__other.jsonl`), - lines({ ...HEADER, schema_version: READABLE_JOURNAL_SCHEMA_VERSION + 1 }) - ); - - await expect( - new RunJournalReaderAdapter(projectRoot).listForeignSchemas() - ).resolves.toStrictEqual([READABLE_JOURNAL_SCHEMA_VERSION + 1]); - }); - - it("skips a directory that merely carries the run file extension", async () => { - await mkdir(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`)); - const adapter = new RunJournalReaderAdapter(projectRoot); - - await expect(adapter.listForeignSchemas()).resolves.toStrictEqual([]); - await expect(adapter.list()).resolves.toStrictEqual([]); - await expect(adapter.read(SESSION_ID)).resolves.toBeNull(); - }); - }); - - describe("deleteRunFile", () => { - it("names the refused name and the directory in the error it throws", async () => { - const adapter = new RunJournalReaderAdapter(projectRoot); - - await expect(adapter.deleteRunFile(runsDir, "../escape.jsonl")).rejects.toThrow( - `refusing to delete "../escape.jsonl" — not a run file name inside ${runsDir}` - ); - }); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts deleted file mode 100644 index c852ad31e..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { buildTaskIntervals } from "../../../../src/contexts/telemetry/domain/task-attribution.js"; -import { RunJournalReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; -import { environmentWithoutGitVariables } from "../../../../src/runtime/git/git-environment.js"; -import { journalTaskDeclared } from "../../../helpers/telemetry-journal-hook.js"; - -// A ticket declared on a host whose payload names no path at all, exercised against the real -// hook and the real reader with nothing stubbed between them. -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; -const SESSION_ID = "22222222-2222-4222-8222-222222222222"; -const TASK_PATH = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/spec.md"; - -describe("task_declared, from the hook that writes it to the reader that reads it", () => { - let projectRoot: string; - let runsDir: string; - - beforeEach(async () => { - projectRoot = realpathSync(await mkdtemp(join(tmpdir(), "aidd-task-declared-"))); - execFileSync("git", ["init", "-q", projectRoot], { - env: environmentWithoutGitVariables(process.env), - }); - runsDir = join(projectRoot, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await mkdir(join(projectRoot, ".aidd"), { recursive: true }); - await writeFile( - join(projectRoot, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }) - ); - await writeFile( - join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), - `${JSON.stringify({ - type: "session_start", - at: "2026-08-21T09:00:00Z", - run_id: RUN_ID, - tool: "codex", - vendor_id: SESSION_ID, - })}\n` - ); - }); - - afterEach(async () => { - await rm(projectRoot, { recursive: true, force: true }); - }); - - // Codex has no write-path field on any tool and no distinct "Read" tool: a Bash command - // reading the plan is the only shape it ever sends. - function readViaBashPayload(): Record { - return { - tool_name: "Bash", - tool_input: { command: `sed -n '1,50p' ${TASK_PATH}` }, - cwd: projectRoot, - session_id: "a-different-id", - }; - } - - it("appends a repository-relative path the reader surfaces as declared", async () => { - journalTaskDeclared.handleTaskDeclared(readViaBashPayload(), "codex", SESSION_ID); - - const journal = await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); - - expect(journal?.taskDeclarations.map((declared) => declared.path)).toEqual([TASK_PATH]); - }); - - it("uses the session id it is handed, not the payload's own spelling", async () => { - journalTaskDeclared.handleTaskDeclared(readViaBashPayload(), "codex", SESSION_ID); - - const journal = await new RunJournalReaderAdapter(projectRoot).read("a-different-id"); - - expect(journal).toBeNull(); - }); - - it("declares nothing for a call that names no task path at all", async () => { - const payload = readViaBashPayload(); - payload.tool_input = { command: "cat cli/src/index.ts" }; - - journalTaskDeclared.handleTaskDeclared(payload, "codex", SESSION_ID); - - expect( - (await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID))?.taskDeclarations - ).toEqual([]); - }); - - it("derives a bounded interval a report can attribute a record against", async () => { - journalTaskDeclared.handleTaskDeclared(readViaBashPayload(), "codex", SESSION_ID); - - const journal = await new RunJournalReaderAdapter(projectRoot).read(SESSION_ID); - expect(journal).not.toBeNull(); - const intervals = buildTaskIntervals( - journal ?? { boundaries: [], filesWritten: [], taskDeclarations: [] } - ); - - expect(intervals).toHaveLength(1); - expect(intervals[0].path).toBe(TASK_PATH); - // Unclosed - no turn_end followed it - so the interval ends at its own start, never at - // Infinity: a session that crashed here must not attribute a week of later reads to it. - expect(intervals[0].endMs).toBe(intervals[0].startMs); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts deleted file mode 100644 index e61f7f3d8..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { TaskBacklogAdapter } from "../../../../src/contexts/telemetry/infrastructure/task-backlog-adapter.js"; - -const TASK_FOLDER = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/"; - -const projectRoots: string[] = []; - -afterEach(async () => { - for (const root of projectRoots.splice(0)) await rm(root, { recursive: true, force: true }); -}); - -async function freshProject(): Promise { - const root = await mkdtemp(join(tmpdir(), "aidd-task-backlog-")); - projectRoots.push(root); - return root; -} - -async function writeLink(root: string, body: string): Promise { - const folder = join(root, TASK_FOLDER); - await mkdir(folder, { recursive: true }); - await writeFile(join(folder, "backlog-link.json"), body, "utf8"); -} - -/** Every file under `dir`, hashed by its own bytes — the whole set, so a file the read path - * *created* is caught exactly as a file it modified would be. */ -async function snapshot(dir: string): Promise> { - const files = new Map(); - const walk = async (current: string): Promise => { - let entries: import("node:fs").Dirent[]; - try { - entries = await readdir(current, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const full = join(current, entry.name); - if (entry.isDirectory()) await walk(full); - else if (entry.isFile()) { - const contents = await readFile(full); - files.set(relative(dir, full), createHash("sha256").update(contents).digest("hex")); - } - } - }; - await walk(dir); - return files; -} - -describe("TaskBacklogAdapter — reads a declaration without ever writing one", () => { - it("reads a forge reference", async () => { - const root = await freshProject(); - await writeLink( - root, - JSON.stringify({ - backlog: "ai-driven-dev/framework#617", - written_at: "2026-08-21T09:00:00Z", - written_by: "aidd-pm:04-spec", - }) - ); - const adapter = new TaskBacklogAdapter(root); - - const declaration = await adapter.read(TASK_FOLDER); - - expect(declaration).toEqual({ - kind: "declared", - link: { - backlog: "ai-driven-dev/framework#617", - writtenAt: "2026-08-21T09:00:00Z", - writtenBy: "aidd-pm:04-spec", - }, - }); - }); - - it("reads a project-relative Markdown path as the same kind of row", async () => { - const root = await freshProject(); - await writeLink( - root, - JSON.stringify({ - backlog: "aidd_docs/backlog/tasks/telemetry-v1.md", - written_at: "2026-08-21T09:00:00Z", - written_by: "aidd-dev:01-plan", - }) - ); - const adapter = new TaskBacklogAdapter(root); - - const declaration = await adapter.read(TASK_FOLDER); - - expect(declaration.kind).toBe("declared"); - expect(declaration.kind === "declared" && declaration.link.backlog).toBe( - "aidd_docs/backlog/tasks/telemetry-v1.md" - ); - }); - - // The path handed in is repository-relative, and joining it to the process working - // directory finds nothing - spelled `{ kind: "none" }`, a claim about the task, not the read. - it("anchors at the repository root, so a subdirectory reads the same declaration", async () => { - const root = await freshProject(); - await mkdir(join(root, ".git"), { recursive: true }); - await writeLink( - root, - JSON.stringify({ - backlog: "acme/widgets#661", - written_at: "2026-08-21T10:00:00Z", - written_by: "aidd-vcs:01-commit", - }) - ); - const subdirectory = join(root, "cli", "nested"); - await mkdir(subdirectory, { recursive: true }); - - const declaration = await new TaskBacklogAdapter(subdirectory).read(TASK_FOLDER); - - expect(declaration.kind).toBe("declared"); - }); - - it("answers none for a folder that declares nothing — a normal state, not an error", async () => { - const root = await freshProject(); - await mkdir(join(root, TASK_FOLDER), { recursive: true }); - const adapter = new TaskBacklogAdapter(root); - - await expect(adapter.read(TASK_FOLDER)).resolves.toEqual({ kind: "none" }); - }); - - it("answers none for a task folder that does not exist at all", async () => { - const root = await freshProject(); - const adapter = new TaskBacklogAdapter(root); - - await expect(adapter.read(TASK_FOLDER)).resolves.toEqual({ kind: "none" }); - }); - - it("answers unreadable for a file that is not valid JSON, distinct from none", async () => { - const root = await freshProject(); - await writeLink(root, "{ not json"); - const adapter = new TaskBacklogAdapter(root); - - await expect(adapter.read(TASK_FOLDER)).resolves.toEqual({ kind: "unreadable" }); - }); - - it("answers unreadable for valid JSON missing the backlog field", async () => { - const root = await freshProject(); - await writeLink( - root, - JSON.stringify({ written_at: "2026-08-21T09:00:00Z", written_by: "aidd-pm:04-spec" }) - ); - const adapter = new TaskBacklogAdapter(root); - - await expect(adapter.read(TASK_FOLDER)).resolves.toEqual({ kind: "unreadable" }); - }); - - it("answers unreadable for valid JSON missing written_at alone", async () => { - const root = await freshProject(); - await writeLink( - root, - JSON.stringify({ backlog: "ai-driven-dev/framework#617", written_by: "aidd-pm:04-spec" }) - ); - - await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ - kind: "unreadable", - }); - }); - - it("answers unreadable for valid JSON missing written_by alone", async () => { - const root = await freshProject(); - await writeLink( - root, - JSON.stringify({ backlog: "ai-driven-dev/framework#617", written_at: "2026-08-21T09:00:00Z" }) - ); - - await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ - kind: "unreadable", - }); - }); - - it("answers unreadable for an empty backlog reference", async () => { - const root = await freshProject(); - await writeLink( - root, - JSON.stringify({ - backlog: "", - written_at: "2026-08-21T09:00:00Z", - written_by: "aidd-pm:04-spec", - }) - ); - - await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ - kind: "unreadable", - }); - }); - - it("answers unreadable for a backlog reference that is not a string", async () => { - const root = await freshProject(); - await writeLink( - root, - JSON.stringify({ - backlog: 617, - written_at: "2026-08-21T09:00:00Z", - written_by: "aidd-pm:04-spec", - }) - ); - - await expect(new TaskBacklogAdapter(root).read(TASK_FOLDER)).resolves.toStrictEqual({ - kind: "unreadable", - }); - }); - - it("answers unreadable when the file is there but cannot be read, distinct from none", async () => { - const root = await freshProject(); - await mkdir(join(root, "aidd_docs", "tasks", "t", "backlog-link.json"), { recursive: true }); - - await expect(new TaskBacklogAdapter(root).read("aidd_docs/tasks/t/")).resolves.toStrictEqual({ - kind: "unreadable", - }); - }); - - it("answers unreadable for a declaration missing its provenance", async () => { - const root = await freshProject(); - await writeLink(root, JSON.stringify({ backlog: "ai-driven-dev/framework#617" })); - const adapter = new TaskBacklogAdapter(root); - - await expect(adapter.read(TASK_FOLDER)).resolves.toEqual({ kind: "unreadable" }); - }); - - it("never writes: the whole project tree is byte-identical after every kind of read", async () => { - const root = await freshProject(); - await writeLink( - root, - JSON.stringify({ - backlog: "ai-driven-dev/framework#617", - written_at: "2026-08-21T09:00:00Z", - written_by: "aidd-pm:04-spec", - }) - ); - const adapter = new TaskBacklogAdapter(root); - const before = await snapshot(root); - - await adapter.read(TASK_FOLDER); - await adapter.read("aidd_docs/tasks/2026_08/no-declaration/"); - await adapter.read("aidd_docs/tasks/2026_08/does-not-exist/"); - - const after = await snapshot(root); - expect(after).toEqual(before); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/task-backlog-skill-shape.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/task-backlog-skill-shape.integration.test.ts deleted file mode 100644 index eebc56a12..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/task-backlog-skill-shape.integration.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { readFileSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { TaskBacklogAdapter } from "../../../../src/contexts/telemetry/infrastructure/task-backlog-adapter.js"; -import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; - -/** Fed through the real adapter, never a stand-in parser, so a rename on either side fails. */ -const REPO_ROOT = REPOSITORY_ROOT; -const SPEC_EXAMPLE_JSON = join( - REPO_ROOT, - "plugins", - "aidd-pm", - "skills", - "04-spec", - "assets", - "backlog-link-template.json" -); -const PLAN_SKILL_MD = join( - REPO_ROOT, - "plugins", - "aidd-dev", - "skills", - "01-plan", - "actions", - "04-plan.md" -); - -function taughtExample(path: string): string | null { - const text = readFileSync(path, "utf8"); - if (path.endsWith(".json")) return text.trim(); - const match = /^[ \t]*```json\r?\n([\s\S]*?)\r?\n[ \t]*```/mu.exec(text); - return match?.[1] ?? null; -} - -const tempDirs: string[] = []; - -afterEach(async () => { - for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }); -}); - -async function projectWithLink(json: string): Promise<{ root: string; taskFolder: string }> { - const root = await mkdtemp(join(tmpdir(), "aidd-backlog-skill-shape-")); - tempDirs.push(root); - const taskFolder = "aidd_docs/tasks/2026_08/2026_08_21_example/"; - await mkdir(join(root, taskFolder), { recursive: true }); - await writeFile(join(root, taskFolder, "backlog-link.json"), json, "utf8"); - return { root, taskFolder }; -} - -describe.each([ - ["aidd-pm:04-spec", SPEC_EXAMPLE_JSON], - ["aidd-dev:01-plan", PLAN_SKILL_MD], -])("%s's own backlog-link.json example matches what the reader accepts", (_skill, path) => { - it("names a JSON example at all (guards against a no-op extraction)", () => { - expect(taughtExample(path)).not.toBeNull(); - }); - - it("parses through the real TaskBacklogAdapter as a declared item", async () => { - const example = taughtExample(path); - if (example === null) throw new Error("no json example to test"); - - const { root, taskFolder } = await projectWithLink(`${example}\n`); - const adapter = new TaskBacklogAdapter(root); - - const declaration = await adapter.read(taskFolder); - - expect(declaration.kind).toBe("declared"); - if (declaration.kind === "declared") { - expect(declaration.link.backlog).toBe("owner/repo#123"); - expect(declaration.link.writtenAt.length).toBeGreaterThan(0); - expect(declaration.link.writtenBy.length).toBeGreaterThan(0); - } - }); -}); - -describe("both skills agree with each other, not only with the reader", () => { - it("write the identical field names, so neither can drift from the other unnoticed", () => { - const specExample = taughtExample(SPEC_EXAMPLE_JSON); - const planExample = taughtExample(PLAN_SKILL_MD); - expect(specExample).not.toBeNull(); - expect(planExample).not.toBeNull(); - - const fieldNames = (json: string): readonly string[] => - Object.keys(JSON.parse(json) as Record).sort(); - - expect(fieldNames(specExample as string)).toEqual(fieldNames(planExample as string)); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts deleted file mode 100644 index 2a94dbdd0..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -// Every tool, because `enabledPluginsCandidates` walks the whole registry: a partial -// registration throws before a single assertion is reached. -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import { TelemetryEvidenceAdapter } from "../../../../src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.js"; - -/** - * `HOME` is a throwaway directory for every case: `readRecorderDeclaration` checks the - * user-scope Claude settings file, which a developer may have the plugin enabled in. - */ -const created: string[] = []; -const savedHome = process.env.HOME; - -afterEach(() => { - if (savedHome === undefined) delete process.env.HOME; - else process.env.HOME = savedHome; - for (const dir of created) rmSync(dir, { recursive: true, force: true }); - created.length = 0; -}); - -function project(): string { - const root = mkdtempSync(join(tmpdir(), "aidd-evidence-")); - created.push(root); - process.env.HOME = join(root, "home"); - mkdirSync(process.env.HOME, { recursive: true }); - return join(root, "project"); -} - -function write(path: string, content: string): void { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, content); -} - -function writeJson(path: string, value: unknown): void { - write(path, JSON.stringify(value, null, 2)); -} - -const adapter = () => new TelemetryEvidenceAdapter(); - -describe("whether measurement is allowed here", () => { - it("reads a project that turned it on", async () => { - const root = project(); - writeJson(join(root, ".aidd", "config.json"), { telemetry: { enabled: true } }); - - expect(await adapter().isTelemetryEnabled(root, {})).toBe(true); - }); - - it("reads a project that never decided as off, without a file to read", async () => { - expect(await adapter().isTelemetryEnabled(project(), {})).toBe(false); - }); - - // The person's own refusal outranks the project's file — the rule `telemetry-switch.ts` - // states and `repo.cjs` mirrors for the hook, checked here on the route the CLI takes. - it("lets a person refuse in their own environment, over a project that turned it on", async () => { - const root = project(); - writeJson(join(root, ".aidd", "config.json"), { telemetry: { enabled: true } }); - - expect(await adapter().isTelemetryEnabled(root, { AIDD_TELEMETRY: "0" })).toBe(false); - }); - - it("treats a switch file that is not JSON as off, never as on", async () => { - const root = project(); - write(join(root, ".aidd", "config.json"), "{ telemetry: enabled, }"); - - expect(await adapter().isTelemetryEnabled(root, {})).toBe(false); - }); -}); - -describe("what the switch setup reports, beside the answer itself", () => { - it("names the file it read, and reads a damaged one as unreadable rather than off", async () => { - const root = project(); - write(join(root, ".aidd", "config.json"), "not json at all"); - - const setup = await adapter().readSwitchSetup(root); - - expect(setup.path).toBe(join(root, ".aidd", "config.json")); - expect(setup.readable).toBe(false); - expect(setup.enabled).toBe(false); - }); - - // An absent file is a project that never chose, which is a different fact from one whose - // file cannot be read — and only the second is something wrong. - it("reads an absent file as readable and undecided", async () => { - const setup = await adapter().readSwitchSetup(project()); - - expect(setup.readable).toBe(true); - expect(setup.enabled).toBe(false); - }); - - it("reads a project that turned it on as readable and enabled, naming the file", async () => { - const root = project(); - writeJson(join(root, ".aidd", "config.json"), { telemetry: { enabled: true } }); - - expect(await adapter().readSwitchSetup(root)).toStrictEqual({ - path: join(root, ".aidd", "config.json"), - enabled: true, - readable: true, - }); - }); - - it("reads a switch path that is a directory as unreadable, not as absent", async () => { - const root = project(); - mkdirSync(join(root, ".aidd", "config.json"), { recursive: true }); - - expect(await adapter().readSwitchSetup(root)).toStrictEqual({ - path: join(root, ".aidd", "config.json"), - enabled: false, - readable: false, - }); - }); -}); - -describe("where the recorder declaration is looked for", () => { - it("checks the manifest, both enabledPlugins files, the three Claude hook scopes and Cursor's hooks file, once each", async () => { - const root = project(); - const home = process.env.HOME ?? ""; - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration).toStrictEqual({ - declared: false, - declaredAt: [], - locationsChecked: [ - join(root, ".aidd", "manifest.json"), - join(root, ".claude", "settings.json"), - join(root, ".github", "copilot", "settings.json"), - join(root, ".claude", "settings.local.json"), - join(home, ".claude", "settings.json"), - join(root, ".cursor", "hooks.json"), - ], - unreadable: [], - }); - }); -}); - -describe("what the manifest says about the recorder", () => { - async function declarationFor(manifest: unknown) { - const root = project(); - writeJson(join(root, ".aidd", "manifest.json"), manifest); - const declaration = await adapter().readRecorderDeclaration(root); - return { declaration, manifestFile: join(root, ".aidd", "manifest.json") }; - } - - it("names the manifest alone as the declaring location", async () => { - const { declaration, manifestFile } = await declarationFor({ - tools: { claude: { plugins: [{ name: "aidd-telemetry", version: "1.0.0" }] } }, - }); - - expect(declaration.declaredAt).toStrictEqual([manifestFile]); - expect(declaration.unreadable).toStrictEqual([]); - }); - - it("finds the recorder under a second tool when the first declares other plugins only", async () => { - const { declaration, manifestFile } = await declarationFor({ - tools: { - codex: { plugins: [{ name: "aidd-dev", version: "1.0.0" }] }, - claude: { - plugins: [ - { name: "aidd-dev", version: "1.0.0" }, - { name: "aidd-telemetry", version: "1.0.0" }, - ], - }, - }, - }); - - expect(declaration.declaredAt).toStrictEqual([manifestFile]); - }); - - it("reads a manifest declaring only other plugins as not declaring the recorder", async () => { - const { declaration } = await declarationFor({ - tools: { claude: { plugins: [{ name: "aidd-dev", version: "1.0.0" }] } }, - }); - - expect(declaration.declared).toBe(false); - expect(declaration.unreadable).toStrictEqual([]); - }); - - it.each([ - ["a JSON null", null], - ["no tools key", {}], - ["a tool entry that is not an object", { tools: { claude: "installed" } }], - ["a plugins value that is not a list", { tools: { claude: { plugins: "aidd-telemetry" } } }], - [ - "a plugin entry that is not an object", - { tools: { claude: { plugins: ["aidd-telemetry"] } } }, - ], - ])("reads a manifest holding %s as not declaring, and not as unreadable", async (_, manifest) => { - const { declaration } = await declarationFor(manifest); - - expect(declaration.declared).toBe(false); - expect(declaration.declaredAt).toStrictEqual([]); - expect(declaration.unreadable).toStrictEqual([]); - }); - - it("names a damaged manifest as the one unreadable location", async () => { - const root = project(); - write(join(root, ".aidd", "manifest.json"), "{ tools: "); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.unreadable).toStrictEqual([join(root, ".aidd", "manifest.json")]); - expect(declaration.declaredAt).toStrictEqual([]); - }); - - it("names a manifest path that is a directory as unreadable, never as absent", async () => { - const root = project(); - mkdirSync(join(root, ".aidd", "manifest.json"), { recursive: true }); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.unreadable).toStrictEqual([join(root, ".aidd", "manifest.json")]); - }); -}); - -describe("what a tool's enabledPlugins says about the recorder", () => { - it("names Copilot's settings file alone when only it enables the recorder", async () => { - const root = project(); - writeJson(join(root, ".github", "copilot", "settings.json"), { - enabledPlugins: { "aidd-telemetry@aidd-framework": true }, - }); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.declaredAt).toStrictEqual([ - join(root, ".github", "copilot", "settings.json"), - ]); - expect(declaration.unreadable).toStrictEqual([]); - }); - - it("finds the recorder among other enabled plugins", async () => { - const root = project(); - writeJson(join(root, ".claude", "settings.json"), { - enabledPlugins: { "aidd-dev@aidd-framework": true, "aidd-telemetry@aidd-framework": true }, - }); - - expect((await adapter().readRecorderDeclaration(root)).declaredAt).toStrictEqual([ - join(root, ".claude", "settings.json"), - ]); - }); - - it("reads a key enabling some other plugin as not declaring the recorder", async () => { - const root = project(); - writeJson(join(root, ".claude", "settings.json"), { - enabledPlugins: { "aidd-dev@aidd-framework": true }, - }); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.declared).toBe(false); - expect(declaration.unreadable).toStrictEqual([]); - }); - - it.each([ - ["a JSON null", null], - ["no enabledPlugins key", {}], - ])( - "reads a settings file holding %s as not declaring, and not as unreadable", - async (_, settings) => { - const root = project(); - writeJson(join(root, ".claude", "settings.json"), settings); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.declaredAt).toStrictEqual([]); - expect(declaration.unreadable).toStrictEqual([]); - } - ); - - it("names a damaged settings file as the one unreadable location", async () => { - const root = project(); - write(join(root, ".github", "copilot", "settings.json"), "{ enabledPlugins: "); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.unreadable).toStrictEqual([ - join(root, ".github", "copilot", "settings.json"), - ]); - expect(declaration.declaredAt).toStrictEqual([]); - }); -}); - -describe("what a hooks block says about the recorder", () => { - it("names the user-scope Claude settings file when its hooks block invokes the entry point", async () => { - const root = project(); - const homeSettings = join(process.env.HOME ?? "", ".claude", "settings.json"); - writeJson(homeSettings, { - hooks: { - SessionStart: [ - { - hooks: [{ type: "command", command: "node .claude/hooks/aidd-telemetry/journal.cjs" }], - }, - ], - }, - }); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.declaredAt).toStrictEqual([homeSettings]); - expect(declaration.unreadable).toStrictEqual([]); - }); - - it("names Cursor's hooks file when it invokes the entry point from the recorder's own script dir", async () => { - const root = project(); - writeJson(join(root, ".cursor", "hooks.json"), { - version: 1, - hooks: { - sessionStart: [ - { command: "node ./other/journal.cjs" }, - { command: "node .cursor/hooks/aidd-telemetry/journal.cjs" }, - ], - }, - }); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.declaredAt).toStrictEqual([join(root, ".cursor", "hooks.json")]); - }); - - it("refuses the recorder's own script name under a directory that is not its hooks dir", async () => { - const root = project(); - writeJson(join(root, ".cursor", "hooks.json"), { - version: 1, - hooks: { sessionStart: [{ command: "node ./vendor/aidd-telemetry/journal.cjs" }] }, - }); - - expect((await adapter().readRecorderDeclaration(root)).declared).toBe(false); - }); - - it("refuses another script under the plugin token, however its hooks dir is named", async () => { - const root = project(); - writeJson(join(root, ".claude", "settings.local.json"), { - hooks: { - SessionStart: [ - { - hooks: [ - { - type: "command", - // biome-ignore lint/suspicious/noTemplateCurlyInString: Claude Code resolves this placeholder, the settings file carries it verbatim - command: "node ${CLAUDE_PLUGIN_ROOT}/hooks/other.cjs", - }, - ], - }, - ], - }, - }); - - expect((await adapter().readRecorderDeclaration(root)).declared).toBe(false); - }); - - it("names a damaged hooks file as the one unreadable location", async () => { - const root = project(); - write(join(root, ".cursor", "hooks.json"), "{ version: 1, "); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.unreadable).toStrictEqual([join(root, ".cursor", "hooks.json")]); - expect(declaration.declaredAt).toStrictEqual([]); - }); -}); - -describe("whether anything is declared to do the recording", () => { - it("finds the recorder in the manifest a plugin install writes", async () => { - const root = project(); - writeJson(join(root, ".aidd", "manifest.json"), { - tools: { claude: { plugins: [{ name: "aidd-telemetry", version: "1.0.0" }] } }, - }); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.declared).toBe(true); - expect(declaration.declaredAt).toContain(join(root, ".aidd", "manifest.json")); - }); - - // The marketplace half of the key is this project's own choice, so the match is on the - // plugin's name and the `@` that follows it, never the whole key. - it("finds it in enabledPlugins whatever marketplace the key names", async () => { - const root = project(); - writeJson(join(root, ".claude", "settings.json"), { - enabledPlugins: { "aidd-telemetry@some-marketplace": true }, - }); - - expect((await adapter().readRecorderDeclaration(root)).declared).toBe(true); - }); - - it("finds it in a Claude hooks block that names the entry point by its plugin token", async () => { - const root = project(); - writeJson(join(root, ".claude", "settings.local.json"), { - hooks: { - SessionStart: [ - { - hooks: [ - { - type: "command", - // biome-ignore lint/suspicious/noTemplateCurlyInString: Claude Code resolves this placeholder, the settings file carries it verbatim - command: "node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.cjs session-start", - }, - ], - }, - ], - }, - }); - - expect((await adapter().readRecorderDeclaration(root)).declared).toBe(true); - }); - - // A bare `journal.cjs` is any plugin's journal, not this one's — matching it would read - // somebody else's hook as this recorder being installed. - it("refuses a hooks block naming a bare journal.cjs belonging to some other plugin", async () => { - const root = project(); - writeJson(join(root, ".cursor", "hooks.json"), { - version: 1, - hooks: { SessionStart: [{ command: "node ./somewhere-else/journal.cjs" }] }, - }); - - expect((await adapter().readRecorderDeclaration(root)).declared).toBe(false); - }); - - it("reports nothing declared, and still names every location it looked in", async () => { - const declaration = await adapter().readRecorderDeclaration(project()); - - expect(declaration.declared).toBe(false); - expect(declaration.declaredAt).toEqual([]); - expect(declaration.locationsChecked.length).toBeGreaterThan(3); - expect(new Set(declaration.locationsChecked).size).toBe(declaration.locationsChecked.length); - }); - - // A file that is there and damaged is not a file that says "not declared": the first is - // something to fix, the second is an ordinary state. - it("names a damaged location as unreadable rather than counting it as undeclared", async () => { - const root = project(); - write(join(root, ".aidd", "manifest.json"), "{ tools: "); - - const declaration = await adapter().readRecorderDeclaration(root); - - expect(declaration.unreadable).toContain(join(root, ".aidd", "manifest.json")); - expect(declaration.declared).toBe(false); - }); -}); - -describe("a payload that matched no known host", () => { - it("reads the moment one arrived", async () => { - const root = project(); - write( - join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), - `${JSON.stringify({ type: "unrecognised_payload", at: "2026-03-02T08:00:00Z" })}\n` - ); - - expect(await adapter().readUnrecognisedPayload(root)).toEqual({ at: "2026-03-02T08:00:00Z" }); - }); - - it("answers nothing when the file is absent", async () => { - expect(await adapter().readUnrecognisedPayload(project())).toBeNull(); - }); - - it("answers nothing for a line that is not one of these records", async () => { - const root = project(); - write(join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), '{"type":"something-else"}\n'); - - expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); - }); - - it("answers nothing for another record kind even when it carries a moment", async () => { - const root = project(); - write( - join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), - `${JSON.stringify({ type: "something-else", at: "2026-03-02T08:00:00Z" })}\n` - ); - - expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); - }); - - it("answers nothing when the moment is not a string", async () => { - const root = project(); - write( - join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), - `${JSON.stringify({ type: "unrecognised_payload", at: 1772438400 })}\n` - ); - - expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); - }); - - it("skips blank lines before the first record", async () => { - const root = project(); - write( - join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), - `\n \n${JSON.stringify({ type: "unrecognised_payload", at: "2026-03-02T08:00:00Z" })}\n` - ); - - expect(await adapter().readUnrecognisedPayload(root)).toStrictEqual({ - at: "2026-03-02T08:00:00Z", - }); - }); - - // The hook writing this file anchors at the repository root, never at the directory a - // session started from, so a reader must walk up rather than join onto `projectRoot`. - it("finds the file from a subdirectory of the repository, not only from its root", async () => { - const root = project(); - mkdirSync(join(root, ".git"), { recursive: true }); - write( - join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), - `${JSON.stringify({ type: "unrecognised_payload", at: "2026-03-02T08:00:00Z" })}\n` - ); - const subdirectory = join(root, "packages", "app"); - mkdirSync(subdirectory, { recursive: true }); - - expect(await adapter().readUnrecognisedPayload(subdirectory)).toEqual({ - at: "2026-03-02T08:00:00Z", - }); - }); -}); - -describe("an export a deleted command left behind in a tool's own settings", () => { - it("names the file and the keys still in it", async () => { - const root = project(); - writeJson(join(root, ".claude", "settings.json"), { - env: { - OTEL_EXPORTER_OTLP_ENDPOINT: "https://example.invalid", - CLAUDE_CODE_ENABLE_TELEMETRY: "1", - }, - }); - - const leftovers = await adapter().findLeftoverExportConfig(root); - - expect(leftovers).toHaveLength(1); - expect(leftovers[0]?.path).toBe(join(root, ".claude", "settings.json")); - expect(leftovers[0]?.keys.length).toBeGreaterThan(0); - }); - - it("finds none in a project whose settings carry no export at all", async () => { - const root = project(); - writeJson(join(root, ".claude", "settings.json"), { env: {} }); - - expect(await adapter().findLeftoverExportConfig(root)).toEqual([]); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts deleted file mode 100644 index 90e8be685..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { appendFile, chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { TelemetrySinkPeriodRead } from "../../../../src/contexts/telemetry/domain/ports/telemetry-sink.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { decideTelemetrySinkRetention } from "../../../../src/contexts/telemetry/domain/telemetry-sink-retention.js"; -import { TelemetrySinkAdapter } from "../../../../src/contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; - -const RECORD: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "export", - tool: "claude", - vendor_id: "s-1", - vendor_field: "session.id", - cost_usd: 1, - step_attribution: "unattributed", -}; - -describe("TelemetrySinkAdapter", () => { - let userConfigDir: string; - - beforeEach(async () => { - userConfigDir = await mkdtemp(join(tmpdir(), "aidd-sink-adapter-")); - }); - - afterEach(async () => { - await rm(userConfigDir, { recursive: true, force: true }); - }); - - it("writes under /telemetry, honoring the constructor override", () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - expect(adapter.rootDir).toBe(join(userConfigDir, "telemetry")); - }); - - it("appends real lines and reports whether the day file was just created", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - - const first = await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); - expect(first.dayFileIsNew).toBe(true); - - const second = await adapter.appendRecord(RECORD, new Date("2026-08-17T11:00:00Z")); - expect(second.dayFileIsNew).toBe(false); - expect(second.filePath).toBe(first.filePath); - - const content = await readFile(first.filePath, "utf8"); - const lines = content.trim().split("\n"); - expect(lines).toHaveLength(2); - expect(JSON.parse(lines[0] ?? "")).toEqual(RECORD); - }); - - it("never rewrites the file it appends to — appendRecord is the only write primitive", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - const { filePath } = await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); - const beforeStat = await readFile(filePath, "utf8"); - await adapter.appendRecord(RECORD, new Date("2026-08-17T12:00:00Z")); - const afterStat = await readFile(filePath, "utf8"); - expect(afterStat.startsWith(beforeStat)).toBe(true); - }); - - it("prunes real day files beyond the window, keeping the newest, on real disk state", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - await adapter.appendRecord(RECORD, new Date("2026-08-15T10:00:00Z")); - await adapter.appendRecord(RECORD, new Date("2026-08-16T10:00:00Z")); - await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); - - const before = await adapter.listDayFiles(); - expect(before).toEqual(["2026-08-15.jsonl", "2026-08-16.jsonl", "2026-08-17.jsonl"]); - - const { keep, prune } = decideTelemetrySinkRetention(before, 2); - for (const fileName of prune) await adapter.deleteDayFile(adapter.rootDir, fileName); - - const after = await adapter.listDayFiles(); - expect(after).toEqual(keep); - expect(after).toEqual(["2026-08-16.jsonl", "2026-08-17.jsonl"]); - }); - - it("finds a vendor's records across every day file, ignoring other vendors", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - const other = { ...RECORD, vendor_id: "s-2" }; - await adapter.appendRecord(RECORD, new Date("2026-08-15T10:00:00Z")); - await adapter.appendRecord(other, new Date("2026-08-15T11:00:00Z")); - await adapter.appendRecord(RECORD, new Date("2026-08-16T10:00:00Z")); - - const records = await adapter.readRecordsForVendor("s-1"); - expect(records).toHaveLength(2); - expect(records.every((r) => r.vendor_id === "s-1")).toBe(true); - }); - - it("tolerates a day file that cannot be read, the same way a full read already does", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - await adapter.appendRecord(RECORD, new Date("2026-08-15T10:00:00Z")); - // `listDayFiles` filters by name alone, so a directory matching the pattern is listed - // and then fails to read — the deterministic stand-in for a file deleted mid-scan. - await mkdir(join(adapter.rootDir, "2026-08-16.jsonl")); - - await expect(adapter.readRecordsForVendor("s-1")).resolves.toHaveLength(1); - }); - - it("skips a torn final line rather than failing the whole scan", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - const { filePath } = await adapter.appendRecord(RECORD, new Date("2026-08-15T10:00:00Z")); - await appendFile(filePath, '{"sink_schema_version":2,"kind":"requ'); - - const records = await adapter.readRecordsForVendor("s-1"); - expect(records).toHaveLength(1); - }); - - it.skipIf(process.platform === "win32")( - "writes a day file readable by this person alone", - async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - - const { filePath } = await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); - - expect(((await stat(filePath)).mode & 0o777).toString(8)).toBe("600"); - } - ); - - it("lists day files only, leaving any other entry of the directory out", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - await adapter.appendRecord(RECORD, new Date("2026-08-17T10:00:00Z")); - await writeFile(join(adapter.rootDir, "notes.txt"), ""); - await writeFile(join(adapter.rootDir, "2026-08-16.jsonl.bak"), ""); - - expect(await adapter.listDayFiles()).toStrictEqual(["2026-08-17.jsonl"]); - }); - - it("lists nothing, rather than failing, before the directory exists", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - - expect(await adapter.listDayFiles()).toStrictEqual([]); - expect(await adapter.readRecordsForVendor("s-1")).toStrictEqual([]); - }); - - it("names the file and the directory it refused to delete outside of", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - - await expect(adapter.deleteDayFile(adapter.rootDir, "../VICTIM.txt")).rejects.toThrow( - `refusing to delete "../VICTIM.txt" — not a day file name inside ${adapter.rootDir}` - ); - }); - - it("deletes a day file that is already gone without complaint", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - - await expect( - adapter.deleteDayFile(adapter.rootDir, "2026-08-01.jsonl") - ).resolves.toBeUndefined(); - }); - - // chmod blocks no write for root or behind Windows ACLs, where this would pass without - // testing anything. - it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( - "fails ensureWritable at startup with a message naming the path, when the directory cannot be written", - async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); // creates rootDir first - await chmod(adapter.rootDir, 0o500); - try { - await expect(adapter.ensureWritable()).rejects.toThrow(adapter.rootDir); - } finally { - await chmod(adapter.rootDir, 0o700); // allow afterEach's rm to succeed - } - } - ); -}); - -describe("TelemetrySinkAdapter.readRecordsInPeriod", () => { - let userConfigDir: string; - let adapter: TelemetrySinkAdapter; - - // Every fixture is appended on one day and stamped with another: a session read days - // later lands in today's file while its records carry their own, older moments. - const STORED_ON = new Date("2026-08-21T09:00:00Z"); - - beforeEach(async () => { - userConfigDir = await mkdtemp(join(tmpdir(), "aidd-sink-period-")); - adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - }); - - afterEach(async () => { - await rm(userConfigDir, { recursive: true, force: true }); - }); - - async function append(vendorId: string, happenedOn: string | undefined): Promise { - const record: TelemetrySinkRecord = { - ...RECORD, - vendor_id: vendorId, - ...(happenedOn === undefined ? {} : { event_timestamp: `${happenedOn}T10:00:00.000Z` }), - }; - await adapter.appendRecord(record, STORED_ON); - } - - function period(from: string, to: string): Promise { - return adapter.readRecordsInPeriod(new Date(`${from}T00:00:00Z`), new Date(`${to}T00:00:00Z`)); - } - - it("selects on when the work ran, not on the day file the line landed in", async () => { - await append("july", "2026-07-29"); - await append("august", "2026-08-18"); - - // Both were appended on the same day, so both live in one day file. - expect(await adapter.listDayFiles()).toEqual(["2026-08-21.jsonl"]); - expect((await period("2026-07-01", "2026-07-31")).records.map((r) => r.vendor_id)).toEqual([ - "july", - ]); - expect((await period("2026-08-01", "2026-08-31")).records.map((r) => r.vendor_id)).toEqual([ - "august", - ]); - }); - - it("returns every record inside the range and none outside it", async () => { - await append("before", "2026-08-16"); - await append("first", "2026-08-17"); - await append("last", "2026-08-19"); - await append("after", "2026-08-20"); - - const read = await period("2026-08-17", "2026-08-19"); - - expect(read.records.map((record) => record.vendor_id)).toEqual(["first", "last"]); - expect(read.skippedLines).toBe(0); - }); - - it("hands back a record with no moment rather than placing it in a period", async () => { - await append("dated", "2026-08-17"); - await append("undated", undefined); - - const read = await period("2026-08-17", "2026-08-17"); - - expect(read.records.map((record) => record.vendor_id)).toEqual(["dated"]); - expect(read.undated.map((record) => record.vendor_id)).toEqual(["undated"]); - }); - - it("keeps a moment-less record out of every period, however wide", async () => { - await append("undated", undefined); - - expect((await period("2000-01-01", "2099-12-31")).records).toEqual([]); - expect((await period("2000-01-01", "2099-12-31")).undated).toHaveLength(1); - }); - - it("reads across sessions, unlike the per-vendor read it sits beside", async () => { - await append("s-a", "2026-08-17"); - await append("s-b", "2026-08-17"); - - const read = await period("2026-08-17", "2026-08-17"); - - expect(read.records.map((record) => record.vendor_id)).toEqual(["s-a", "s-b"]); - expect(await adapter.readRecordsForVendor("s-a")).toHaveLength(1); - }); - - it("skips a torn final line, keeps the file's other lines, and counts what it skipped", async () => { - await append("whole", "2026-08-17"); - await appendFile(join(adapter.rootDir, "2026-08-21.jsonl"), '{"sink_schema_v'); - - const read = await period("2026-08-17", "2026-08-17"); - - expect(read.records.map((record) => record.vendor_id)).toEqual(["whole"]); - expect(read.skippedLines).toBe(1); - }); - - it("skips a line whose schema version this build does not know, and says how many", async () => { - await append("known", "2026-08-17"); - await appendFile( - join(adapter.rootDir, "2026-08-21.jsonl"), - `${JSON.stringify({ ...RECORD, sink_schema_version: 99, vendor_id: "future" })}\n` - ); - - const read = await period("2026-08-17", "2026-08-17"); - - expect(read.records.map((record) => record.vendor_id)).toEqual(["known"]); - expect(read.skippedLines).toBe(1); - }); - - it("places a moment written with a non-UTC offset on the day it actually happened", async () => { - await adapter.appendRecord( - // 01:00+05:00 on the 18th is 20:00Z on the 17th. - { ...RECORD, vendor_id: "offset", event_timestamp: "2026-08-18T01:00:00+05:00" }, - STORED_ON - ); - - expect((await period("2026-08-17", "2026-08-17")).records.map((r) => r.vendor_id)).toEqual([ - "offset", - ]); - expect((await period("2026-08-18", "2026-08-18")).records).toEqual([]); - }); - - it("reads the same period whichever way round the two days are given", async () => { - await append("only", "2026-08-17"); - - const forwards = await period("2026-08-16", "2026-08-18"); - const backwards = await adapter.readRecordsInPeriod( - new Date("2026-08-18T00:00:00Z"), - new Date("2026-08-16T00:00:00Z") - ); - - expect(backwards).toEqual(forwards); - }); - - it("collects every project, step and model any record names, whatever its period", async () => { - await adapter.appendRecord( - { - ...RECORD, - vendor_id: "full", - event_timestamp: "2026-08-17T10:00:00.000Z", - project_id: "p-1", - step: "aidd-dev:01-plan", - model: "claude-opus-5", - }, - STORED_ON - ); - await adapter.appendRecord( - { - ...RECORD, - vendor_id: "outside", - event_timestamp: "2026-07-01T10:00:00.000Z", - project_id: "p-2", - step: "aidd-dev:02-implement", - model: "gpt-5", - }, - STORED_ON - ); - await append("bare", "2026-08-17"); - - const read = await period("2026-08-17", "2026-08-17"); - - expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["full", "bare"]); - expect(read.knownValues).toStrictEqual({ - projects: new Set(["p-1", "p-2"]), - steps: new Set(["aidd-dev:01-plan", "aidd-dev:02-implement"]), - models: new Set(["claude-opus-5", "gpt-5"]), - }); - }); - - it("tolerates a day file that cannot be read, counting nothing for it", async () => { - await append("whole", "2026-08-17"); - await mkdir(join(adapter.rootDir, "2026-08-22.jsonl")); - - const read = await period("2026-08-17", "2026-08-17"); - - expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["whole"]); - expect(read.undated).toStrictEqual([]); - expect(read.skippedLines).toBe(0); - }); - - it("counts a line of nothing but whitespace as blank, not as skipped", async () => { - await append("whole", "2026-08-17"); - await appendFile(join(adapter.rootDir, "2026-08-21.jsonl"), " \n\t\n"); - - const read = await period("2026-08-17", "2026-08-17"); - - expect(read.records.map((record) => record.vendor_id)).toStrictEqual(["whole"]); - expect(read.skippedLines).toBe(0); - }); - - it("answers an empty period with no records and nothing skipped, never an error", async () => { - expect(await period("2026-08-17", "2026-08-18")).toEqual({ - records: [], - undated: [], - skippedLines: 0, - knownValues: { projects: new Set(), steps: new Set(), models: new Set() }, - }); - }); -}); - -describe("the real sink and its in-memory double place a record on the same day", () => { - // A double that buckets differently from the adapter it stands for would let aggregation - // tests agree with the double and disagree with production. Four shapes could diverge. - const MOMENTS: readonly (string | undefined)[] = [ - "2026-08-17T10:00:00.000Z", - "2026-08-18T01:00:00+05:00", - "not-a-moment", - undefined, - ]; - - let userConfigDir: string; - - beforeEach(async () => { - userConfigDir = await mkdtemp(join(tmpdir(), "aidd-sink-agree-")); - }); - - afterEach(async () => { - await rm(userConfigDir, { recursive: true, force: true }); - }); - - it("agrees on which records fall in a period and which carry no moment at all", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - const double = new InMemoryTelemetrySink(); - const storedOn = new Date("2026-08-21T09:00:00Z"); - - for (const [index, at] of MOMENTS.entries()) { - const record: TelemetrySinkRecord = { - ...RECORD, - vendor_id: `v-${index}`, - ...(at === undefined ? {} : { event_timestamp: at }), - }; - await adapter.appendRecord(record, storedOn); - await double.appendRecord(record, storedOn); - } - - const from = new Date("2026-08-17T00:00:00Z"); - const to = new Date("2026-08-17T00:00:00Z"); - const fromAdapter = await adapter.readRecordsInPeriod(from, to); - const fromDouble = await double.readRecordsInPeriod(from, to); - - const ids = (read: TelemetrySinkPeriodRead) => ({ - records: read.records.map((r) => r.vendor_id), - undated: read.undated.map((r) => r.vendor_id), - }); - - // v-0 is the 17th in UTC; v-1 is 01:00+05:00 on the 18th, which is the 17th in UTC. - expect(ids(fromAdapter)).toEqual({ records: ["v-0", "v-1"], undated: ["v-2", "v-3"] }); - expect(ids(fromDouble)).toEqual(ids(fromAdapter)); - }); - - // `deleteDayFile` shares the `isBareFileName` check `RunJournalReaderAdapter.deleteRunFile` - // applies. - it("refuses a relative walk out of the directory it is handed, rather than deleting outside it", async () => { - const adapter = new TelemetrySinkAdapter(userConfigDir); - await adapter.ensureWritable(); - const victimPath = join(userConfigDir, "VICTIM.txt"); - await writeFile(victimPath, "do not delete me\n"); - - // adapter.rootDir -> .. -> userConfigDir: "../VICTIM.txt" lands here. - await expect(adapter.deleteDayFile(adapter.rootDir, "../VICTIM.txt")).rejects.toThrow(); - - expect(await readFile(victimPath, "utf8")).toBe("do not delete me\n"); - }); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts deleted file mode 100644 index cb25500d0..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { - chmodSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - defaultConfigDir, - TelemetrySinkAdapter, -} from "../../../../src/contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; -import { AuthStorage } from "../../../../src/runtime/auth/auth-storage.js"; -import { sandboxedEnv, sinkDirIn } from "../../../e2e/helpers.js"; -import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; - -/** - * `defaultConfigDir` reads `process.platform` on every call, so faking it here pins the rule - * on any machine. `%APPDATA%` is where a Windows application keeps this, `.config` is not. - */ -const PLUGIN_README = join(REPOSITORY_ROOT, "plugins", "aidd-telemetry", "README.md"); - -function withPlatform(platform: NodeJS.Platform, run: () => T): T { - const original = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { value: platform, configurable: true }); - try { - return run(); - } finally { - if (original) Object.defineProperty(process, "platform", original); - } -} - -const previousAppData = process.env.APPDATA; -const previousHome = process.env.HOME; -const temporaryHomes: string[] = []; - -/** A home with no `.config/aidd/telemetry`, so the legacy-data fallback does not fire: a - * machine that already journalled there keeps landing there, only a fresh one gets `%APPDATA%`. */ -function freshHome(): string { - const home = mkdtempSync(join(tmpdir(), "aidd-sink-location-")); - temporaryHomes.push(home); - process.env.HOME = home; - return home; -} - -afterEach(() => { - if (previousAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = previousAppData; - if (previousHome === undefined) delete process.env.HOME; - else process.env.HOME = previousHome; - for (const home of temporaryHomes.splice(0)) rmSync(home, { recursive: true, force: true }); -}); - -describe("where the figures land by default", () => { - it("a POSIX machine keeps them under the OS user's own .config", () => { - const home = freshHome(); - - expect(withPlatform("linux", defaultConfigDir)).toBe(join(home, ".config", "aidd")); - }); - - it("a fresh Windows machine keeps them under %APPDATA%, never under .config", () => { - freshHome(); - process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); - - expect(withPlatform("win32", defaultConfigDir)).toBe(join(process.env.APPDATA, "aidd")); - }); - - it("Windows without APPDATA falls back rather than inventing a path", () => { - const home = freshHome(); - delete process.env.APPDATA; - - expect(withPlatform("win32", defaultConfigDir)).toBe(join(home, ".config", "aidd")); - }); - - it("a Windows machine that already journalled under .config keeps landing there", () => { - const home = freshHome(); - process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); - mkdirSync(join(home, ".config", "aidd", "telemetry"), { recursive: true }); - writeFileSync(join(home, ".config", "aidd", "telemetry", "notes.txt"), ""); - writeFileSync(join(home, ".config", "aidd", "telemetry", "2026-08-17.jsonl"), ""); - - expect(withPlatform("win32", defaultConfigDir)).toBe(join(home, ".config", "aidd")); - }); - - it("a Windows machine whose .config holds no day file is a fresh one", () => { - const home = freshHome(); - process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); - mkdirSync(join(home, ".config", "aidd", "telemetry"), { recursive: true }); - writeFileSync(join(home, ".config", "aidd", "telemetry", "notes.jsonl.txt"), ""); - - expect(withPlatform("win32", defaultConfigDir)).toBe(join(process.env.APPDATA, "aidd")); - }); - - it("a POSIX machine ignores APPDATA even when it is set", () => { - const home = freshHome(); - process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); - - expect(withPlatform("linux", defaultConfigDir)).toBe(join(home, ".config", "aidd")); - }); -}); - -describe("which variable located the figures", () => { - const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; - const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; - - afterEach(() => { - for (const [key, value] of [ - ["AIDD_TELEMETRY_DIR", previousTelemetryDir], - ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], - ] as const) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - it("is the figures' own name when AIDD_TELEMETRY_DIR is set, whatever else is", () => { - process.env.AIDD_TELEMETRY_DIR = join(tmpdir(), "figures"); - process.env.AIDD_USER_CONFIG_DIR = join(tmpdir(), "older"); - - expect(new TelemetrySinkAdapter().locatedBy).toBe("telemetry-dir"); - }); - - it("is the older config variable when only that one is set", () => { - delete process.env.AIDD_TELEMETRY_DIR; - process.env.AIDD_USER_CONFIG_DIR = join(tmpdir(), "older"); - - expect(new TelemetrySinkAdapter().locatedBy).toBe("user-config-dir"); - }); - - it("is the older config variable when the constructor names the directory", () => { - delete process.env.AIDD_TELEMETRY_DIR; - delete process.env.AIDD_USER_CONFIG_DIR; - - expect(new TelemetrySinkAdapter(join(tmpdir(), "older")).locatedBy).toBe("user-config-dir"); - }); - - it("is the default when nothing names a location", () => { - freshHome(); - delete process.env.AIDD_TELEMETRY_DIR; - delete process.env.AIDD_USER_CONFIG_DIR; - - expect(new TelemetrySinkAdapter().locatedBy).toBe("default"); - }); - - it("the plugin README states the exact default the code writes", () => { - // Forward slashes rather than `join`, which yields `~\\.config\\aidd` on Windows: the - // prose reads the same on every platform, only the code follows the host's separator. - const documented = "~/.config/aidd/telemetry"; - const text = readFileSync(PLUGIN_README, "utf8"); - - expect(text).toContain(documented); - // The variable the adapter honours, not the older one a reader is only told to avoid. - expect(text).toContain("AIDD_TELEMETRY_DIR"); - }); -}); - -/** - * `sandboxedEnv` sets `AIDD_USER_CONFIG_DIR` and the adapter honours it ahead of - * `defaultConfigDir()`, so a sandboxed sink sits under the fake home on every platform. - */ -describe("a sandboxed run's sink, agreed between the helper and the adapter", () => { - const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; - - afterEach(() => { - if (previousUserConfigDir === undefined) delete process.env.AIDD_USER_CONFIG_DIR; - else process.env.AIDD_USER_CONFIG_DIR = previousUserConfigDir; - }); - - for (const platform of ["linux", "win32"] as const) { - it(`agrees on ${platform}, whichever platform this suite runs on`, () => { - const home = freshHome(); - const env = sandboxedEnv(home); - const previousPlatformAppData = process.env.APPDATA; - process.env.APPDATA = env.APPDATA; - process.env.AIDD_USER_CONFIG_DIR = env.AIDD_USER_CONFIG_DIR; - try { - const fromAdapter = withPlatform(platform, () => new TelemetrySinkAdapter().rootDir); - const fromHelper = withPlatform(platform, () => sinkDirIn(home)); - - expect(fromHelper).toBe(fromAdapter); - } finally { - if (previousPlatformAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = previousPlatformAppData; - } - }); - } -}); - -/** - * `AIDD_USER_CONFIG_DIR` also names where `auth.json` — a GitHub token — is written, so - * sharing the figures through it shared the token. The measurement has its own name. - */ -describe("where the figures land, and what does not follow them there", () => { - const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; - const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; - - afterEach(() => { - for (const [key, value] of [ - ["AIDD_TELEMETRY_DIR", previousTelemetryDir], - ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], - ] as const) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - it("puts the figures exactly where AIDD_TELEMETRY_DIR names, not in a subdirectory of it", () => { - // This variable names the directory the day files sit in; `AIDD_USER_CONFIG_DIR` names - // the directory above it. - const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); - try { - process.env.AIDD_TELEMETRY_DIR = shared; - delete process.env.AIDD_USER_CONFIG_DIR; - - expect(new TelemetrySinkAdapter().rootDir).toBe(shared); - } finally { - rmSync(shared, { recursive: true, force: true }); - } - }); - - it("leaves the token where it was when the figures are shared", () => { - const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); - const home = mkdtempSync(join(tmpdir(), "aidd-home-")); - try { - delete process.env.AIDD_USER_CONFIG_DIR; - const tokenBefore = new AuthStorage().userConfigPath(); - - process.env.AIDD_TELEMETRY_DIR = shared; - - expect(new TelemetrySinkAdapter().rootDir).toBe(shared); - expect(new AuthStorage().userConfigPath()).toBe(tokenBefore); - } finally { - rmSync(shared, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - } - }); - - it("still honours the older variable, so a setup that predates the split keeps working", () => { - const older = mkdtempSync(join(tmpdir(), "aidd-legacy-config-")); - try { - delete process.env.AIDD_TELEMETRY_DIR; - process.env.AIDD_USER_CONFIG_DIR = older; - - expect(new TelemetrySinkAdapter().rootDir).toBe(join(older, "telemetry")); - } finally { - rmSync(older, { recursive: true, force: true }); - } - }); - - it("prefers the name given to the figures when both are set", () => { - const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); - const older = mkdtempSync(join(tmpdir(), "aidd-legacy-config-")); - try { - process.env.AIDD_TELEMETRY_DIR = shared; - process.env.AIDD_USER_CONFIG_DIR = older; - - expect(new TelemetrySinkAdapter().rootDir).toBe(shared); - } finally { - rmSync(shared, { recursive: true, force: true }); - rmSync(older, { recursive: true, force: true }); - } - }); -}); - -/** - * The directory's mode decides who may list a person's working days. A default location is - * tightened; one they named themselves is left as made, since sharing is what naming it is for. - */ -describe("who may list the days a person worked", () => { - const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; - const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; - const previousHome = process.env.HOME; - - afterEach(() => { - for (const [key, value] of [ - ["AIDD_TELEMETRY_DIR", previousTelemetryDir], - ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], - ["HOME", previousHome], - ] as const) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - function modeOf(dir: string): string { - return (statSync(dir).mode & 0o777).toString(8); - } - - it.skipIf(process.platform === "win32")( - "tightens a default location to this person alone", - async () => { - const home = mkdtempSync(join(tmpdir(), "aidd-tighten-home-")); - try { - delete process.env.AIDD_TELEMETRY_DIR; - delete process.env.AIDD_USER_CONFIG_DIR; - process.env.HOME = home; - - const sink = new TelemetrySinkAdapter(); - await sink.ensureWritable(); - - expect(modeOf(sink.rootDir)).toBe("700"); - } finally { - rmSync(home, { recursive: true, force: true }); - } - } - ); - - it.skipIf(process.platform === "win32")( - "leaves a location a person named themselves exactly as they made it", - async () => { - const home = mkdtempSync(join(tmpdir(), "aidd-tighten-home-")); - const shared = join(mkdtempSync(join(tmpdir(), "aidd-tighten-shared-")), "figures"); - try { - process.env.HOME = home; - delete process.env.AIDD_USER_CONFIG_DIR; - process.env.AIDD_TELEMETRY_DIR = shared; - mkdirSync(shared, { recursive: true }); - chmodSync(shared, 0o755); - - const sink = new TelemetrySinkAdapter(); - await sink.ensureWritable(); - - // Untouched: a directory a team shares must stay listable by the team. - expect(modeOf(shared)).toBe("755"); - } finally { - rmSync(home, { recursive: true, force: true }); - rmSync(shared, { recursive: true, force: true }); - } - } - ); -}); diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts deleted file mode 100644 index f5e53fe8a..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-windows-acl.integration.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { TelemetrySinkAdapter } from "../../../../src/contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; - -const RECORD: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "export", - tool: "claude", - vendor_id: "s-1", - vendor_field: "session.id", - cost_usd: 1, - step_attribution: "unattributed", -}; - -const ENV_KEYS = [ - "PATH", - "HOME", - "APPDATA", - "USERDOMAIN", - "USERNAME", - "ICACLS_LOG", - "AIDD_TELEMETRY_DIR", - "AIDD_USER_CONFIG_DIR", -] as const; - -const DAY = new Date("2026-08-17T10:00:00Z"); - -describe.skipIf(process.platform === "win32")( - "a default location on Windows is restricted to this account through icacls", - () => { - const previousEnv = new Map(); - const previousPlatform = Object.getOwnPropertyDescriptor(process, "platform"); - let sandbox: string; - let logPath: string; - - beforeEach(() => { - for (const key of ENV_KEYS) previousEnv.set(key, process.env[key]); - sandbox = mkdtempSync(join(tmpdir(), "aidd-sink-acl-")); - const bin = join(sandbox, "bin"); - mkdirSync(bin); - logPath = join(sandbox, "icacls.log"); - writeFileSync(join(bin, "icacls"), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$ICACLS_LOG"\n'); - chmodSync(join(bin, "icacls"), 0o755); - process.env.PATH = `${bin}:${process.env.PATH ?? ""}`; - process.env.ICACLS_LOG = logPath; - process.env.HOME = join(sandbox, "home"); - process.env.APPDATA = join(sandbox, "appdata"); - process.env.USERDOMAIN = "ACME"; - process.env.USERNAME = "ada"; - delete process.env.AIDD_TELEMETRY_DIR; - delete process.env.AIDD_USER_CONFIG_DIR; - Object.defineProperty(process, "platform", { value: "win32", configurable: true }); - }); - - afterEach(() => { - if (previousPlatform) Object.defineProperty(process, "platform", previousPlatform); - for (const key of ENV_KEYS) { - const value = previousEnv.get(key); - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - rmSync(sandbox, { recursive: true, force: true }); - }); - - function calls(): string[] { - try { - return readFileSync(logPath, "utf8").trim().split("\n"); - } catch { - return []; - } - } - - it("grants the directory to DOMAIN\\user alone, recursively, on ensureWritable", async () => { - const sink = new TelemetrySinkAdapter(); - - await sink.ensureWritable(); - - expect(sink.rootDir).toBe(join(sandbox, "appdata", "aidd", "telemetry")); - expect(calls()).toStrictEqual([ - `${sink.rootDir} /inheritance:r /grant:r ACME\\ada:(OI)(CI)F /T /C /Q`, - ]); - }); - - it("grants a day file to this user alone, once, on the append that creates it", async () => { - const sink = new TelemetrySinkAdapter(); - - const { filePath } = await sink.appendRecord(RECORD, DAY); - await sink.appendRecord(RECORD, DAY); - - const dir = `${sink.rootDir} /inheritance:r /grant:r ACME\\ada:(OI)(CI)F /T /C /Q`; - expect(calls()).toStrictEqual([ - dir, - `${filePath} /inheritance:r /grant:r ACME\\ada:F /C /Q`, - dir, - ]); - }); - - it("names the user without a domain when USERDOMAIN is unset", async () => { - delete process.env.USERDOMAIN; - const sink = new TelemetrySinkAdapter(); - - await sink.ensureWritable(); - - expect(calls()).toStrictEqual([ - `${sink.rootDir} /inheritance:r /grant:r ada:(OI)(CI)F /T /C /Q`, - ]); - }); - - it("runs icacls for nobody when no account name resolves", async () => { - delete process.env.USERDOMAIN; - process.env.USERNAME = ""; - const sink = new TelemetrySinkAdapter(); - - await sink.ensureWritable(); - await sink.appendRecord(RECORD, DAY); - - expect(calls()).toStrictEqual([]); - }); - - it("leaves a location the person named themselves untouched, directory and day file alike", async () => { - process.env.AIDD_TELEMETRY_DIR = join(sandbox, "shared"); - const sink = new TelemetrySinkAdapter(); - - await sink.ensureWritable(); - await sink.appendRecord(RECORD, DAY); - - expect(calls()).toStrictEqual([]); - }); - } -); diff --git a/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts deleted file mode 100644 index dcd0454df..000000000 --- a/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; -import { createClaudeCodeTranscriptAccumulator } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; -import { createCodexRolloutAccumulator } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; -import { TranscriptCostReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.js"; -import { CLAUDE_CODE_TRANSCRIPT_LOCATION } from "../../../../src/contexts/tools/domain/profiles/claude/claude-transcript-location.js"; -import { CODEX_ROLLOUT_LOCATION } from "../../../../src/contexts/tools/domain/profiles/codex/codex-transcript-location.js"; - -// The fixtures tree mirrors a real $HOME, each tool's directory exactly where it would -// write it, so `homeDir` here exercises the same walk and file naming a real machine does. -const HOME_DIR = fileURLToPath(new URL("../../../fixtures/local-cost", import.meta.url)).replace( - /\/$/, - "" -); - -const CLAUDE_SID = "22222222-2222-4222-8222-222222222222"; -const CODEX_TARGET_ID = "019fae6f-2009-7cd3-86b2-b8f83481b160"; -const CODEX_PARENT_ID = "019f69d0-9e1f-7951-86c9-ddb23cfd51f4"; - -describe("TranscriptCostReaderAdapter — Claude Code", () => { - const adapter = new TranscriptCostReaderAdapter( - HOME_DIR, - CLAUDE_CODE_TRANSCRIPT_LOCATION, - createClaudeCodeTranscriptAccumulator - ); - - it("reads both the main transcript and a subagent's own file for one session", async () => { - const { records } = await adapter.read(CLAUDE_SID); - - // 3 real turns from the main transcript (one API call's two lines collapsed to one) - // plus 1 from the subagent's own file. - expect(records).toHaveLength(4); - expect(records.filter((r) => r.agent_name === "Explore")).toHaveLength(1); - }); - - it("says it found no session, not that the session cost nothing, when no file names it", async () => { - expect(await adapter.read("no-such-session")).toEqual({ records: [], sessionFound: false }); - }); - - const created: string[] = []; - afterEach(() => { - for (const dir of created) rmSync(dir, { recursive: true, force: true }); - created.length = 0; - }); - - it("walks only directories and regular files, so a symlink named like a transcript is no session", async () => { - const home = mkdtempSync(join(tmpdir(), "aidd-transcript-walk-")); - created.push(home); - const projectDir = join(home, ".claude", "projects", "fake-project"); - mkdirSync(projectDir, { recursive: true }); - symlinkSync( - join(HOME_DIR, ".claude", "projects", "fake-project", `${CLAUDE_SID}.jsonl`), - join(projectDir, `${CLAUDE_SID}.jsonl`) - ); - const linkedAdapter = new TranscriptCostReaderAdapter( - home, - CLAUDE_CODE_TRANSCRIPT_LOCATION, - createClaudeCodeTranscriptAccumulator - ); - - await expect(linkedAdapter.read(CLAUDE_SID)).resolves.toEqual({ - records: [], - sessionFound: false, - }); - }); - - it("answers with nothing, not an error, when the declared root does not exist", async () => { - const adapterWithNoHome = new TranscriptCostReaderAdapter( - `${HOME_DIR}/does-not-exist`, - CLAUDE_CODE_TRANSCRIPT_LOCATION, - createClaudeCodeTranscriptAccumulator - ); - - await expect(adapterWithNoHome.read(CLAUDE_SID)).resolves.toEqual({ - records: [], - sessionFound: false, - }); - }); -}); - -describe("TranscriptCostReaderAdapter — Codex", () => { - const adapter = new TranscriptCostReaderAdapter( - HOME_DIR, - CODEX_ROLLOUT_LOCATION, - createCodexRolloutAccumulator - ); - - it("resolves a resumed session by its own id, never its parent's, even with both on disk", async () => { - const { records } = await adapter.read(CODEX_TARGET_ID); - - expect(records).toHaveLength(2); - expect(records.every((r) => r.vendor_id === CODEX_TARGET_ID)).toBe(true); - }); - - it("resolves the parent's own session independently, not the resumed session's records", async () => { - const { records } = await adapter.read(CODEX_PARENT_ID); - - expect(records).toHaveLength(1); - expect(records[0]?.vendor_id).toBe(CODEX_PARENT_ID); - expect(records[0]?.turn_id).toBe("019f69d1-8dcc-7272-a9eb-523ef9976475"); - }); - - it("says it found no session for an id no rollout file names", async () => { - expect(await adapter.read("no-such-session")).toEqual({ records: [], sessionFound: false }); - }); - - it("finds the resumed session, so a report never reads 38% of Codex sessions as absent", async () => { - // The trap this guards: the journal hook and this reader must name a resumed session - // the same way. 124 of 330 rollouts measured on one machine are resumed. - expect((await adapter.read(CODEX_TARGET_ID)).sessionFound).toBe(true); - }); -}); diff --git a/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts index 38c580fd4..29ab070ab 100644 --- a/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { flattenCopilotHooksShape, - hookCommandsForEvent, mergeClaudeSettingsHooks, mergeCodexFrameworkHooksJson, mergeCursorFlatHooks, @@ -365,53 +364,6 @@ describe("mergeCodexFrameworkHooksJson", () => { }); }); -describe("hookCommandsForEvent", () => { - it("reads a command out of Claude's nested matcher-group shape", () => { - const content = JSON.stringify({ - hooks: { SessionStart: [{ hooks: [{ type: "command", command: "run.js" }] }] }, - }); - expect(hookCommandsForEvent(content, "SessionStart")).toEqual(["run.js"]); - }); - - it("reads a command out of Codex's nested shape without Cursor's event rename", () => { - const content = JSON.stringify({ - hooks: { SessionStart: [{ matcher: "startup", hooks: [{ command: "codex-run.js" }] }] }, - }); - expect(hookCommandsForEvent(content, "SessionStart")).toEqual(["codex-run.js"]); - }); - - it("reads a command out of Copilot's flat shape, event name unchanged", () => { - const content = JSON.stringify({ - version: 1, - hooks: { SessionStart: [{ type: "command", command: "copilot-run.js" }] }, - }); - expect(hookCommandsForEvent(content, "SessionStart")).toEqual(["copilot-run.js"]); - }); - - it("reads a command out of Cursor's flat shape via CURSOR_EVENT_MAP's renamed event", () => { - const content = JSON.stringify({ - version: 1, - hooks: { sessionStart: [{ command: "cursor-run.js" }] }, - }); - expect(hookCommandsForEvent(content, "SessionStart")).toEqual(["cursor-run.js"]); - }); - - it("returns nothing for an event the file never registered", () => { - const content = JSON.stringify({ - hooks: { PostToolUse: [{ hooks: [{ command: "run.js" }] }] }, - }); - expect(hookCommandsForEvent(content, "SessionStart")).toEqual([]); - }); - - it("returns nothing rather than throwing on content this module never wrote", () => { - expect(hookCommandsForEvent("not json", "SessionStart")).toEqual([]); - expect(hookCommandsForEvent(JSON.stringify({ enabledPlugins: {} }), "SessionStart")).toEqual( - [] - ); - expect(hookCommandsForEvent(JSON.stringify({ hooks: [] }), "SessionStart")).toEqual([]); - }); -}); - describe("renameCodexHookEvents", () => { it("returns a document without hooks byte for byte", () => { expect(renameCodexHookEvents('{"x":1}')).toBe('{"x":1}'); @@ -544,23 +496,6 @@ describe("mergeCodexFrameworkHooksJson, entry by entry", () => { }); }); -describe("hookCommandsForEvent, on content that is not a hooks file", () => { - it("answers nothing for a document that is not an object", () => { - expect(hookCommandsForEvent("[]", "Stop")).toStrictEqual([]); - expect(hookCommandsForEvent("5", "Stop")).toStrictEqual([]); - }); - - it("skips an entry that is not an object, and a command that is not a string", () => { - const content = JSON.stringify({ - hooks: { - Stop: [null, "x", { command: 5 }, { command: "c" }, { hooks: [{ command: "d" }, 3] }], - }, - }); - - expect(hookCommandsForEvent(content, "Stop")).toStrictEqual(["c", "d"]); - }); -}); - describe("a matcher group that declares no hooks list", () => { const groupless = JSON.stringify({ hooks: { Stop: [{ matcher: "x" }, { hooks: [{ command: "a" }] }] }, @@ -580,13 +515,3 @@ describe("a matcher group that declares no hooks list", () => { }); }); }); - -describe("hookCommandsForEvent, for an event Cursor never renames", () => { - it("reads the event under its own name alone", () => { - const content = JSON.stringify({ - hooks: { PreCompact: [{ command: "a" }], preCompact: [{ command: "b" }] }, - }); - - expect(hookCommandsForEvent(content, "PreCompact")).toStrictEqual(["a"]); - }); -}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts deleted file mode 100644 index 16cc16469..000000000 --- a/cli/tests/contexts/tools/domain/profiles/codex/codex-transcript-location.unit.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { CODEX_ROLLOUT_LOCATION } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-transcript-location.js"; - -describe("where Codex keeps a session's rollout", () => { - it("claims no file that is not a rollout, even one named for the session", () => { - expect( - CODEX_ROLLOUT_LOCATION.matches(join("2026", "01", "02", "history-abc.jsonl"), "abc") - ).toBe(false); - }); -}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts index ea4bef401..d38cb0658 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs"; -import { join, posix } from "node:path"; import { describe, expect, it } from "vitest"; import type { ArtifactContract, @@ -11,12 +9,6 @@ import { } from "../../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; import { StubAssetProvider } from "../../../../../helpers/ports/stub-asset-provider.js"; -import { REPOSITORY_ROOT } from "../../../../../helpers/repository-root.js"; - -const OPENCODE_PLUGIN_MODULE = readFileSync( - join(REPOSITORY_ROOT, "plugins/aidd-telemetry/hooks/opencode-plugin.js"), - "utf8" -); // Built, not written literally: biome reads a string holding "${...}" as a lost template. const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; @@ -218,20 +210,6 @@ describe("buildOpencodeFlatContract()", () => { }); }); - it("delivers journal.cjs where the shipped plugin module resolves it, not beside the loader", () => { - const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); - const named = - /JOURNAL_SCRIPT = fileURLToPath\(\s*new URL\("([^"]+)", import\.meta\.url\)/u.exec( - OPENCODE_PLUGIN_MODULE - )?.[1]; - const loaderEntry = hooks.path("aidd-telemetry", "hooks/opencode-plugin.js"); - - expect(named).toBeDefined(); - expect(posix.normalize(posix.join(posix.dirname(loaderEntry), named ?? ""))).toBe( - hooks.path("aidd-telemetry", "hooks/journal.cjs") - ); - }); - it("generates no bridge for a plugin whose hooks name no event OpenCode delivers", () => { const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts index c281acb9e..54d2e32da 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts @@ -46,8 +46,7 @@ describe("generateOpencodeHooksBridge", () => { import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; - // Never process.execPath: OpenCode ships as its own standalone binary (see - // plugins/aidd-telemetry/hooks/opencode-plugin.js:29-30) - that path names \`opencode\` + // Never process.execPath: OpenCode ships as its own standalone binary - that path names \`opencode\` // itself, not a Node runtime able to run this plugin's own hook scripts. const HOOKS_DIR = fileURLToPath(new URL("../hooks/aidd-sample/", import.meta.url)); @@ -55,10 +54,8 @@ describe("generateOpencodeHooksBridge", () => { const STOP = [{"script":"journal.cjs","args":["turn-end"]}]; const POST_TOOL_USE = [{"script":"journal.cjs","args":["tool-used"],"matcher":"Bash"}]; - // Asynchronous on purpose, unlike opencode-plugin.js's own spawnSync: that file spawns at - // most one script per event, this one can spawn one per matching hook across every mapped - // event, and blocking OpenCode's event loop once per hook multiplies the cost its own - // comment already accepts for a single call. A failed spawn (ENOENT, a killed timeout) + // Asynchronous on purpose: this can spawn one script per matching hook across every mapped + // event, and blocking OpenCode's event loop once per hook would multiply the cost. A failed spawn (ENOENT, a killed timeout) // must not throw past this function - both listeners below, plus the caller's own // try/catch, exist because a spawn that never launches can still throw on the stdin write. function runHook(script, args, payload, directory) { @@ -74,8 +71,7 @@ describe("generateOpencodeHooksBridge", () => { /** Pure: \`session.idle\` -> every Stop hook's own {script, args, payload} - or \`[]\` for * any other event. Exported as a property (never a second named export - F6) so this - * generated module's own mapping can be asserted without spawning anything, the same seam - * \`AiddTelemetry.journalCallFor\` already gives opencode-plugin.js. */ + * generated module's own mapping can be asserted without spawning anything. */ function stopCallsFor(event, directory) { if (event?.type !== "session.idle") return []; const sessionId = event.properties?.sessionID; @@ -111,8 +107,7 @@ describe("generateOpencodeHooksBridge", () => { export const AiddSampleHooks = async (input) => { // SessionStart's own approximation (module doc comment above): fired once here, never - // per session. Silent on purpose, the same rule journal.cjs's own main() and - // opencode-plugin.js's own event handler both state: a measurement or a memory refresh + // per session. Silent on purpose: a memory refresh // that breaks OpenCode's own startup is worse than one that never ran. try { for (const hook of SESSION_START) { diff --git a/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts b/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts index 86460f37c..b37d89642 100644 --- a/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts +++ b/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts @@ -17,7 +17,6 @@ import { getAllRegisteredTools, getToolConfig, isAiTool, - journalHostToAiToolId, machineLocalFilesOf, projectHooksFileOf, userMachineLocalFilesOf, @@ -30,9 +29,7 @@ import { distributionProbesOf, marketplaceProbes, } from "../../../../src/contexts/translate/domain/plugin-format.js"; -import type { TelemetryLocalRead } from "../../../../src/kernel/measurement.js"; import { AI_TOOL_IDS, type ToolId } from "../../../../src/kernel/tool.js"; -import { journalHost } from "../../../helpers/telemetry-journal-hook.js"; /** Every assertion iterates the registry rather than a hardcoded list, so adding a tool file * subjects it to all of them instead of letting it misbehave at runtime. */ @@ -112,50 +109,6 @@ describe("AiTool contract conformance", () => { `${toolId} declares marketplaceSettings without nativeActivation — its settings.json declaration is never registered with the runtime that resolves plugins` ).not.toBeNull(); }); - - // Same shape guard for local-read: the type system requires `telemetryLocalRead` to - // exist, but not that its `kind` is one of the two this union defines. - it("declares its local-read shape as declared or explicitly unsupported", () => { - const kinds: readonly TelemetryLocalRead["kind"][] = ["declared", "unsupported"]; - expect( - kinds, - `${toolId} declares an unrecognized telemetryLocalRead kind: ${tool.telemetryLocalRead.kind}` - ).toContain(tool.telemetryLocalRead.kind); - if (tool.telemetryLocalRead.kind === "unsupported") { - expect( - tool.telemetryLocalRead.reason.length, - `${toolId}: telemetryLocalRead.reason must not be empty` - ).toBeGreaterThan(0); - } - }); - }); -}); - -// Cursor's local-read reason is a measured fact, not a guess; Copilot is read at session -// rather than request granularity. -describe("telemetryLocalRead — exact declarations, phase 2 of local-cost-read", () => { - const EXPECTED: Record = { - claude: { kind: "declared" }, - codex: { kind: "declared" }, - opencode: { kind: "declared" }, - copilot: { kind: "declared" }, - cursor: { kind: "unsupported", reason: "token count" }, - kilo: { kind: "unsupported", reason: "OpenTelemetry is experimental" }, - }; - - it.each(Object.entries(EXPECTED))("%s", (toolId, expected) => { - const tool = registeredAiTools.find(([id]) => id === toolId)?.[1]; - if (!tool) throw new Error(`${toolId} is not registered`); - - const shape = tool.telemetryLocalRead; - expect(shape.kind).toBe(expected.kind); - if (shape.kind === "unsupported" && expected.reason) { - expect(shape.reason).toContain(expected.reason); - } - }); - - it("covers exactly the five registered AI tools — no tool escapes this check", () => { - expect(Object.keys(EXPECTED).sort()).toEqual(registeredAiTools.map(([id]) => id).sort()); }); }); @@ -168,57 +121,6 @@ describe("no parallel list references an unregistered tool", () => { ); } }); - - it("every host the journal hook writes for is claimed by exactly one tool declaration", () => { - // These declarations relate the hook's own host name to a toolId, so a host the hook - // writes for and nothing declares joins to nothing, silently. - for (const host of journalHost.DECLARED_HOSTS) { - expect( - journalHostToAiToolId(host), - `the journal hook writes for host "${host}", which no registered AI tool declares as its telemetryJournalHost` - ).not.toBeNull(); - } - }); - - it("declares no journal host the hook does not write for", () => { - for (const [toolId, config] of registeredAiTools) { - const declared = config.telemetryJournalHost; - if (declared === undefined) continue; - expect( - journalHost.DECLARED_HOSTS.has(declared), - `"${toolId}" declares telemetryJournalHost "${declared}", which the journal hook never writes` - ).toBe(true); - } - }); - - it("resolves an unknown host to null rather than to a nearby tool", () => { - expect(journalHostToAiToolId("not-a-host")).toBeNull(); - }); - - it("declares task attributability exactly where journal attribution is possible at all", () => { - // A declared task carries no per-host gate the way a written path or a step does, so - // attributability collapses to whether a host reaches the journal hook at all. - for (const [toolId, config] of registeredAiTools) { - const host = config.telemetryJournalHost; - const hookReachesToolUse = host !== undefined; - - expect( - config.telemetryTaskAttributable, - `"${toolId}" declares telemetryTaskAttributable ${config.telemetryTaskAttributable}, but the journal hook ${hookReachesToolUse ? "does" : "never"} dispatch a tool-used event for host "${host}"` - ).toBe(hookReachesToolUse); - } - }); - - it("declares what its local-read route supplies, for every tool", () => { - for (const [toolId, config] of registeredAiTools) { - const declaration = config.telemetryLocalRead; - if (declaration.kind !== "declared") continue; - expect( - declaration.supplies, - `"${toolId}" declares a telemetryLocalRead route without saying what it supplies` - ).toBeDefined(); - } - }); }); /** A real contract that builds nothing. `buildTargetModesOf` reads which keys a profile @@ -249,8 +151,6 @@ function fakeTool(overrides: Partial>): AiTool { toolSuffix: ".md", signalDir: null, capabilities: {}, - telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, - telemetryTaskAttributable: false, rewriteContent: (content) => content, ...overrides, }; diff --git a/cli/tests/contexts/tools/domain/registry.unit.test.ts b/cli/tests/contexts/tools/domain/registry.unit.test.ts index 31c9db3d4..e5c24bb39 100644 --- a/cli/tests/contexts/tools/domain/registry.unit.test.ts +++ b/cli/tests/contexts/tools/domain/registry.unit.test.ts @@ -29,8 +29,6 @@ function stubTool( toolSuffix: `.${toolId}.md`, signalDir, displayName: toolId, - telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, - telemetryTaskAttributable: false, capabilities, rewriteContent: (content: string) => content, }; diff --git a/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts b/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts deleted file mode 100644 index e45b601e7..000000000 --- a/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/kilo/profile.js"; -import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; -import { mapCodexRolloutToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; -import { mapCopilotEventsToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/copilot-events.js"; -import { mapOpencodeExportToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/opencode-export.js"; -import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { getAiToolConfig } from "../../../../src/contexts/tools/domain/registry.js"; -import type { TelemetryRouteSupply } from "../../../../src/kernel/measurement.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../../src/kernel/tool.js"; - -/** A declaration is checked against what the captures measured, never the documentation, so - * a route claiming an amount its reader never sets fails here. Local read is the only route. */ -type Route = "local"; - -function fixture(relativePath: string): string { - return readFileSync( - fileURLToPath(new URL(`../../../fixtures/${relativePath}`, import.meta.url)), - { - encoding: "utf8", - } - ); -} - -const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; -const CODEX_SESSION = "019fae6f-2009-7cd3-86b2-b8f83481b160"; -const COPILOT_SESSION = "33333333-3333-4333-8333-333333333333"; - -/** Whatever a capture yields, reduced to the four facts a route declares. */ -function observe(records: readonly Partial[]): TelemetryRouteSupply { - const some = (has: (record: Partial) => boolean) => records.some(has); - return { - tokenCounters: some( - (record) => - record.input_tokens !== undefined || - record.output_tokens !== undefined || - record.cache_read_tokens !== undefined || - record.cache_creation_tokens !== undefined - ), - amount: some((record) => record.cost_usd !== undefined), - toolStatedStep: some((record) => record.step !== undefined), - agentName: some((record) => record.agent_name !== undefined), - }; -} - -const CAPTURES: ReadonlyMap TelemetryRouteSupply> = new Map([ - [ - // Both files, because both are this session's local read, and only the subagent's own - // carries the field the tool uses to name the running skill. - "claude:local", - () => - observe([ - ...mapClaudeCodeTranscriptToSinkRecords( - fixture(`local-cost/.claude/projects/fake-project/${CLAUDE_SESSION}.jsonl`) - ), - ...mapClaudeCodeTranscriptToSinkRecords( - fixture( - `local-cost/.claude/projects/fake-project/${CLAUDE_SESSION}/subagents/agent-aa81cdef3bb58820c.jsonl` - ) - ), - ]), - ], - [ - "codex:local", - () => - observe( - mapCodexRolloutToSinkRecords( - fixture( - `local-cost/.codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-${CODEX_SESSION}.jsonl` - ) - ) - ), - ], - [ - "copilot:local", - () => - observe( - mapCopilotEventsToSinkRecords( - fixture(`local-cost/.copilot/session-state/${COPILOT_SESSION}/events.jsonl`), - COPILOT_SESSION - ) - ), - ], - [ - "opencode:local", - () => - observe( - mapOpencodeExportToSinkRecords( - JSON.parse(fixture("telemetry-sink/opencode-export.json")), - "ses_probe" - ) - ), - ], -]); - -function declarationOf(tool: AiToolId) { - return getAiToolConfig(tool).telemetryLocalRead; -} - -const route: Route = "local"; - -describe("what a route declares it supplies, against what its reader actually produces", () => { - for (const tool of AI_TOOL_IDS) { - const declaration = declarationOf(tool); - if (declaration.kind !== "declared") continue; - const capture = CAPTURES.get(`${tool}:${route}`); - - if (!capture) { - it(`${tool} declares a ${route} route with no capture, so it may claim nothing`, () => { - // A declared route nobody ever captured carries an identifier and nothing else; - // letting it claim a capability would document a guess as a fact. - expect(declaration.supplies).toEqual({ - tokenCounters: false, - amount: false, - toolStatedStep: false, - agentName: false, - }); - }); - continue; - } - - it(`${tool}'s ${route} route supplies exactly what it declares`, () => { - expect(capture()).toEqual(declaration.supplies); - }); - } - - // Pins `TelemetryLocalRead` at exactly the two kinds a profile can state, so a third - // variant no profile ever produces cannot survive as a branch nothing reaches. - it("declares only 'declared' or 'unsupported' — a route never left unmeasured", () => { - for (const tool of AI_TOOL_IDS) { - expect(declarationOf(tool).kind).toMatch(/^(declared|unsupported)$/u); - } - }); - - it("has a capture for every route that claims to supply anything", () => { - for (const tool of AI_TOOL_IDS) { - const declaration = declarationOf(tool); - if (declaration.kind !== "declared") continue; - const claimsSomething = Object.values(declaration.supplies).some(Boolean); - - expect( - !claimsSomething || CAPTURES.has(`${tool}:${route}`), - `"${tool}" claims its ${route} route supplies something, with no capture to check it against` - ).toBe(true); - } - }); -}); diff --git a/cli/tests/contexts/tools/domain/tool-config.unit.test.ts b/cli/tests/contexts/tools/domain/tool-config.unit.test.ts index ddb69df31..d56a8eea9 100644 --- a/cli/tests/contexts/tools/domain/tool-config.unit.test.ts +++ b/cli/tests/contexts/tools/domain/tool-config.unit.test.ts @@ -18,8 +18,6 @@ const makeStubConfig = (toolId: AiToolId, toolSuffix: string): AiTool = toolSuffix, signalDir: `.${toolId}/commands`, displayName: toolId, - telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, - telemetryTaskAttributable: false, capabilities: {}, rewriteContent: (content: string) => content, }); diff --git a/cli/tests/e2e/helpers.ts b/cli/tests/e2e/helpers.ts index 304fdac57..3150cebff 100644 --- a/cli/tests/e2e/helpers.ts +++ b/cli/tests/e2e/helpers.ts @@ -205,22 +205,6 @@ export async function copyFixtureTree(sourceDir: string, destDir: string): Promi await cp(sourceDir, destDir, { recursive: true }); } -// Derived here and never imported from the adapter: a test that asked the code where it -// wrote the file could not catch it writing somewhere else. -export function identityFileIn(fakeHome: string): string { - return process.platform === "win32" - ? join(fakeHome, "AppData", "Roaming", "aidd", "identity.json") - : join(fakeHome, ".config", "aidd", "identity.json"); -} - -/** - * `sandboxedEnv` always sets `AIDD_USER_CONFIG_DIR`, which `TelemetrySinkAdapter` honours - * ahead of its platform default — so the sink is under the fake home on every platform. - */ -export function sinkDirIn(fakeHome: string): string { - return join(fakeHome, ".config", "aidd", "telemetry"); -} - export function sandboxedEnv( fakeHome: string, extra?: Record, @@ -260,25 +244,12 @@ export function sandboxedEnv( }; } -// Under `realHome: true` neither `resolveAiddConfigDir` (identity) nor `resolveHomeDir` -// (local cost readers) honors `AIDD_USER_CONFIG_DIR`, so `forget --yes` would delete what it -// finds in the developer's real profile. -function refuseRealHomeForget(args: readonly string[], options?: { realHome?: boolean }): void { - if (options?.realHome && args.includes("forget")) { - throw new Error( - "runCli refuses to run `forget` under realHome: true — identity resolution ignores " + - "AIDD_USER_CONFIG_DIR and would reach the real machine's ~/.config/aidd/identity.json." - ); - } -} - export async function runCli( args: string[], cwd: string, fakeHome: string, options?: { realHome?: boolean; env?: Record } ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - refuseRealHomeForget(args, options); const env = sandboxedEnv(fakeHome, options?.env, options); try { const { stdout, stderr } = await execFileAsync(process.execPath, [cliPath(), ...args], { diff --git a/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts b/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts index 919b2eba2..45b819551 100644 --- a/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts +++ b/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts @@ -47,7 +47,7 @@ describe("E2E: the sandbox a test spawns into", () => { try { const env = sandboxedEnv(fakeHome); - // `hooks/lib/repo.cjs` shells out to git, and every spawned command is node itself. + // A hook shells out to git, and every spawned command is node itself. // A sandbox that reached neither would make this guard pass by breaking everything. expect(await whichUnderSandbox("git", projectDir, env)).not.toBe(""); expect(await whichUnderSandbox("node", projectDir, env)).not.toBe(""); diff --git a/cli/tests/e2e/telemetry-backlog-axis.e2e.test.ts b/cli/tests/e2e/telemetry-backlog-axis.e2e.test.ts deleted file mode 100644 index 24aa4ac0d..000000000 --- a/cli/tests/e2e/telemetry-backlog-axis.e2e.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; -import { join, relative } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -/** The upward link, end to end, through the real CLI binary and real disk. Two properties this - * level alone proves: the report never writes into a task folder while reading it, and two - * tasks declaring the same item merge into one row through the real adapter. */ -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FBX"; -const VENDOR_ID = "55555555-5555-4555-8555-555555555555"; -const PROJECT_ID = "acme/widgets"; -const TASK_ITEM_A = "2026_02/2026_02_10_item-a"; -const TASK_ITEM_B = "2026_02/2026_02_10_item-b"; -const TASK_NONE = "2026_02/2026_02_10_none"; -const BACKLOG_ITEM = "acme/widgets#661"; -const PERIOD = ["--from", "2026-02-01", "--to", "2026-02-28"]; - -const JOURNAL_LINES = [ - { - type: "session_start", - at: "2026-02-10T09:00:00Z", - run_id: RUN_ID, - tool: "codex", - vendor_id: VENDOR_ID, - project_id: PROJECT_ID, - }, - { - type: "task_declared", - at: "2026-02-10T09:10:00Z", - path: `aidd_docs/tasks/${TASK_ITEM_A}/spec.md`, - }, - { - type: "task_declared", - at: "2026-02-10T10:10:00Z", - path: `aidd_docs/tasks/${TASK_ITEM_B}/spec.md`, - }, - { - type: "task_declared", - at: "2026-02-10T11:10:00Z", - path: `aidd_docs/tasks/${TASK_NONE}/spec.md`, - }, - { type: "turn_end", at: "2026-02-10T12:00:00Z" }, -]; - -function record(overrides: Record): Record { - return { - kind: "request", - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - vendor_id: VENDOR_ID, - vendor_field: "session_meta.id", - step_attribution: "unattributed", - project_id: PROJECT_ID, - ...overrides, - }; -} - -const RECORDS = [ - record({ turn_id: "item-a", event_timestamp: "2026-02-10T09:30:00Z", cost_usd: 10 }), - record({ turn_id: "item-b", event_timestamp: "2026-02-10T10:30:00Z", cost_usd: 5 }), - record({ turn_id: "none", event_timestamp: "2026-02-10T11:30:00Z", cost_usd: 3 }), -]; - -interface BacklogRow { - readonly backlog?: string; - readonly declaration?: string; - readonly reason?: string; - readonly totals: { readonly requests: number; readonly cost_micro_usd?: number }; -} - -interface Envelope { - readonly cost_report_version: number; - readonly totals: { readonly requests: number; readonly cost_micro_usd?: number }; - readonly by_backlog: readonly BacklogRow[]; - readonly by_task: readonly { readonly totals: { readonly requests: number } }[]; -} - -function backlogLink(backlog: string, writtenBy: string): string { - return `${JSON.stringify( - { backlog, written_at: "2026-02-10T08:00:00Z", written_by: writtenBy }, - null, - 2 - )}\n`; -} - -/** Every file under `dir`, hashed by its bytes: the whole set, not only files a caller knows - * about, so a file the report *created* is caught exactly as a modified one would be. */ -async function snapshot(dir: string): Promise> { - const files = new Map(); - const walk = async (current: string): Promise => { - let entries: import("node:fs").Dirent[]; - try { - entries = await readdir(current, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const full = join(current, entry.name); - if (entry.isDirectory()) await walk(full); - else if (entry.isFile()) { - files.set( - relative(dir, full), - createHash("sha256") - .update(await readFile(full)) - .digest("hex") - ); - } - } - }; - await walk(dir); - return files; -} - -describe("aidd telemetry report — by_backlog through the real adapter, on real disk", () => { - let cleanup: (() => Promise) | undefined; - - afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - }); - - async function seed(): Promise<{ projectDir: string; fakeHome: string }> { - const env = await createTestEnv("telemetry-backlog-axis"); - cleanup = env.cleanup; - await gitInit(env.projectDir); - await mkdir(join(env.projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(env.projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - "utf-8" - ); - const runsDir = join(env.projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await writeFile( - join(runsDir, `${RUN_ID}__${VENDOR_ID}.jsonl`), - `${JOURNAL_LINES.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8" - ); - // Two tasks declare the same backlog item; one declares none at all - a normal folder, - // no backlog-link.json written into it. - const itemATaskDir = join(env.projectDir, "aidd_docs", "tasks", TASK_ITEM_A); - const itemBTaskDir = join(env.projectDir, "aidd_docs", "tasks", TASK_ITEM_B); - const noneTaskDir = join(env.projectDir, "aidd_docs", "tasks", TASK_NONE); - await mkdir(itemATaskDir, { recursive: true }); - await mkdir(itemBTaskDir, { recursive: true }); - await mkdir(noneTaskDir, { recursive: true }); - await writeFile(join(itemATaskDir, "spec.md"), "# item a\n", "utf-8"); - await writeFile(join(itemBTaskDir, "spec.md"), "# item b\n", "utf-8"); - await writeFile(join(noneTaskDir, "spec.md"), "# none\n", "utf-8"); - await writeFile( - join(itemATaskDir, "backlog-link.json"), - backlogLink(BACKLOG_ITEM, "aidd-pm:04-spec"), - "utf-8" - ); - await writeFile( - join(itemBTaskDir, "backlog-link.json"), - backlogLink(BACKLOG_ITEM, "aidd-dev:01-plan"), - "utf-8" - ); - const sinkDir = join(env.fakeHome, ".config", "aidd", "telemetry"); - await mkdir(sinkDir, { recursive: true }); - await writeFile( - join(sinkDir, "2026-02-28.jsonl"), - `${RECORDS.map((r) => JSON.stringify(r)).join("\n")}\n`, - "utf-8" - ); - return { projectDir: env.projectDir, fakeHome: env.fakeHome }; - } - - it("merges two tasks declaring the same backlog item into one row, and gives the third its own", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - const envelope = JSON.parse(result.stdout) as Envelope; - - const named = envelope.by_backlog.find((row) => row.backlog === BACKLOG_ITEM); - expect(named?.totals.requests).toBe(2); - expect(named?.totals.cost_micro_usd).toBe(15_000_000); - - const none = envelope.by_backlog.find((row) => row.declaration === "none"); - expect(none?.totals.requests).toBe(1); - expect(none?.totals.cost_micro_usd).toBe(3_000_000); - }); - - it("reconciles by_backlog to the same total as the period and as by_task", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - const envelope = JSON.parse(result.stdout) as Envelope; - - const sum = envelope.by_backlog.reduce((total, row) => total + row.totals.requests, 0); - expect(sum).toBe(envelope.totals.requests); - const taskSum = envelope.by_task.reduce((total, row) => total + row.totals.requests, 0); - expect(taskSum).toBe(envelope.totals.requests); - }); - - it("leaves every task folder byte-identical after the report runs", async () => { - const { projectDir, fakeHome } = await seed(); - const tasksDir = join(projectDir, "aidd_docs", "tasks"); - const before = await snapshot(tasksDir); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); - - const after = await snapshot(tasksDir); - expect(after).toEqual(before); - }); - - it("prints the backlog axis through --axis, naming the item and the none row", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli( - ["telemetry", "report", ...PERIOD, "--axis", "backlog"], - projectDir, - fakeHome - ); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("axis: by backlog"); - expect(result.stdout).toContain(BACKLOG_ITEM); - expect(result.stdout).toContain("this task declares no backlog item"); - }); -}); diff --git a/cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts b/cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts deleted file mode 100644 index a56e039fd..000000000 --- a/cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -/** - * Holds `02-check`'s own markdown to what the CLI accepts: nothing else reads that markdown, - * so a command it names that the CLI does not have would ship unnoticed. - */ -const REPO_ROOT = REPOSITORY_ROOT; -const SKILL_DIR = join(REPO_ROOT, "plugins", "aidd-telemetry", "skills", "02-check"); - -/** Every `aidd telemetry …` command the skill's own markdown tells an agent to run. */ -function commandsNamedBySkill(): string[] { - const found = new Set(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith(".md")) { - for (const match of readFileSync(full, "utf8").matchAll(/`(aidd telemetry [^`]+)`/gu)) { - found.add(match[1] ?? ""); - } - } - } - }; - walk(SKILL_DIR); - return [...found]; -} - -describe("E2E: 02-check answers through the CLI", () => { - it("every command the skill names is one the CLI accepts", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("aidd-check-skill-"); - try { - await gitInit(projectDir); - const commands = commandsNamedBySkill(); - - // A closure test over an empty extraction passes vacuously. - expect(commands.length).toBeGreaterThanOrEqual(1); - - for (const command of commands) { - const result = await runCli(command.split(/\s+/u).slice(1), projectDir, fakeHome); - expect(result.exitCode, `${command}\n${result.stderr}`).toBe(0); - } - } finally { - await cleanup(); - } - }); - - it("names no script beside itself any more", () => { - const named = new Set(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith(".md") && /\.cjs\b/u.test(readFileSync(full, "utf8"))) { - named.add(entry.name); - } - } - }; - walk(SKILL_DIR); - - expect([...named]).toEqual([]); - }); -}); diff --git a/cli/tests/e2e/telemetry-check.e2e.test.ts b/cli/tests/e2e/telemetry-check.e2e.test.ts deleted file mode 100644 index 3f9afefa1..000000000 --- a/cli/tests/e2e/telemetry-check.e2e.test.ts +++ /dev/null @@ -1,670 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { cp, mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { createTestEnv, gitInit, identityFileIn, runCli, sinkDirIn } from "./helpers.js"; - -const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); -const REPO_ROOT = REPOSITORY_ROOT; -const JOURNAL_HOOK = join(REPO_ROOT, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); -// Built from two literals, so this definition itself holds no literal `${...}`: biome's -// noTemplateCurlyInString flags a bare `${CLAUDE_PLUGIN_ROOT}` inside a plain string. -const CLAUDE_PLUGIN_ROOT_TOKEN = "$" + "{CLAUDE_PLUGIN_ROOT}"; - -const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; -const CLAUDE_RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; - -async function writeSwitch(projectDir: string, enabled: boolean): Promise { - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled } }) - ); -} - -async function seedJournal( - projectDir: string, - runId: string, - vendorId: string, - sessionStartAt: string, - turnEndAt: string -): Promise { - const runsDir = join(projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - const line = (value: unknown) => `${JSON.stringify(value)}\n`; - await writeFile( - join(runsDir, `${runId}__${vendorId}.jsonl`), - line({ - type: "session_start", - at: sessionStartAt, - run_id: runId, - tool: "claude", - vendor_id: vendorId, - project_id: "acme-widgets", - }) + line({ type: "turn_end", at: turnEndAt }) - ); -} - -async function seedTornRunFile(projectDir: string): Promise { - const runsDir = join(projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await writeFile( - join(runsDir, `${CLAUDE_RUN_ID}__${CLAUDE_SESSION}.jsonl`), - '{"type":"session_start","at":"2026-08-05T19:0' - ); -} - -async function seedUnrecognisedPayload(projectDir: string, at: string): Promise { - const runsDir = join(projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await writeFile( - join(runsDir, "_unrecognised.jsonl"), - `${JSON.stringify({ type: "unrecognised_payload", at })}\n` - ); -} - -/** `~/.codex/config.toml`'s own trust table shape. The event name is a parameter because - * the renamed-event case approves a hook under one name and checks under another. */ -async function writeCodexHookTrust(fakeHome: string, eventName: string): Promise { - await mkdir(join(fakeHome, ".codex"), { recursive: true }); - await writeFile( - join(fakeHome, ".codex", "config.toml"), - `[hooks.state."aidd-telemetry@ai-driven-dev/framework:hooks/hooks.json:${eventName}:0:0"] -trusted_hash = "deadbeef" -` - ); -} - -// Matched by label, not by position: a "what is in place" section prints ahead of the -// four claims, so slicing the first four lines of stdout does not land on them. -const CLAIM_LINE = /^ {2}(hook fired|session journalled|tool files readable|records join)\b/u; - -/** Every claim line the union covers, in the fixed order `diagnoseTelemetryClaims` prints - * in - never the "not covered" lines after them, whose count depends on the machine. */ -function allClaimLines(stdout: string): string[] { - return stdout.split("\n").filter((line) => CLAIM_LINE.test(line)); -} - -describe("aidd telemetry check — the journey and its edge cases", () => { - it("stops at the switch before judging anything else", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-switch-off"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, false); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/measurement is off/u); - expect(result.stdout).not.toContain("hook fired"); - } finally { - await cleanup(); - } - }); - - it("names a non-repository, and never blames the hook", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-not-a-repo"); - try { - await writeSwitch(projectDir, true); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/not a git repository/u); - expect(result.stdout).not.toMatch(/never been observed firing/u); - } finally { - await cleanup(); - } - }); - - it("exits 1 when a claim fails, not 0 with the failure only printed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-exit-code-fail"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.stdout).toContain("FAIL"); - expect(result.exitCode).toBe(1); - } finally { - await cleanup(); - } - }); - - it("names the hook never firing when measurement is on and no run file appears", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-never-fired"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch(/hook fired\s+FAIL\s+no run file/u); - expect(result.stdout).toMatch(/never been observed firing/u); - // "Nothing has run yet" is not "everything is broken": with no journal at all, the - // three claims that read from it have no material to judge, and say so. - expect(result.stdout).toMatch(/session journalled\s+--/u); - expect(result.stdout).toMatch(/tool files readable\s+--/u); - expect(result.stdout).toMatch(/records join\s+--/u); - } finally { - await cleanup(); - } - }); - - it("names an unrecognised payload, not a hook that never ran", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-unrecognised"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await seedUnrecognisedPayload(projectDir, "2026-08-22T09:00:00Z"); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch(/matched no known host/u); - expect(result.stdout).toContain("2026-08-22T09:00:00Z"); - } finally { - await cleanup(); - } - }); - - it("names an anchorless run file as its own failure, not an unrecognised payload, for a run file torn before session_start ever parsed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-torn"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await seedTornRunFile(projectDir); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(1); - // A run file demonstrably exists here (torn though it is) — this is never read as - // "no run file": that would say a file this build can see does not exist. - expect(result.stdout).toMatch(/hook fired\s+FAIL\s+1 run file\(s\)/u); - expect(result.stdout).toMatch(/none carry a readable session_start/u); - expect(result.stdout).not.toMatch(/matched no known host/u); - expect(result.stdout).not.toMatch(/no run file in/u); - } finally { - await cleanup(); - } - }); - - it("settles every claim — none is ever printed as not yet judged", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-settled"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await cp(LOCAL_COST_FIXTURES, fakeHome, { recursive: true }); - await seedJournal( - projectDir, - CLAUDE_RUN_ID, - CLAUDE_SESSION, - "2026-08-05T19:00:00Z", - "2026-08-05T20:00:00Z" - ); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CODEX_THREAD_ID: "", CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).not.toMatch(/not yet judged/u); - expect(allClaimLines(result.stdout)).toHaveLength(4); - expect(result.stdout).not.toContain("export"); - expect(result.stdout).not.toContain("identifier joinable"); - // A healthy, working install — everything the chain needs has already happened — - // reports no failing claim at all. - expect(result.stdout).not.toContain("FAIL"); - } finally { - await cleanup(); - } - }); - - it("names an untrusted Codex hook, not a hook that never fired", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-codex-untrusted"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeCodexHookTrust(fakeHome, "session_start"); - // Approved, but never actually trusted: parseHookTrust only reads `trusted: true` - // from a `trusted_hash =` line directly beneath the header — none is written here. - await writeFile( - join(fakeHome, ".codex", "config.toml"), - `[hooks.state."aidd-telemetry@ai-driven-dev/framework:hooks/hooks.json:session_start:0:0"]\n` - ); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CODEX_THREAD_ID: "codex-1" }, - }); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch( - /hook fired\s+FAIL\s+Codex has not trusted this plugin's hook/u - ); - expect(result.stdout).not.toMatch(/never been observed firing/u); - } finally { - await cleanup(); - } - }); - - it("reports a hook approved under an old event name as untrusted — approval is per entry", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-codex-renamed-event"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeCodexHookTrust(fakeHome, "session-start-legacy"); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CODEX_THREAD_ID: "codex-1" }, - }); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch( - /hook fired\s+FAIL\s+Codex has not trusted this plugin's hook/u - ); - } finally { - await cleanup(); - } - }); - - it("falls back to never-fired, never a guess at trust, when Codex's config.toml is absent entirely", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-codex-no-config"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CODEX_THREAD_ID: "codex-1" }, - }); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch(/hook fired\s+FAIL\s+no run file/u); - expect(result.stdout).toMatch(/never been observed firing/u); - expect(result.stdout).toMatch(/could not be read either/u); - expect(result.stdout).not.toMatch(/has not trusted/u); - } finally { - await cleanup(); - } - }); - /** `unrecognised_payload` is written by the hook and read by `telemetry-evidence-adapter.ts`; - * seeding the marker by hand would only check the reader against a literal this file typed. */ - it("names an unrecognised payload the real hook wrote, not one this test typed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-unrecognised-real"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - - // Neither a transcript path nor a timestamp: the shape no declared host matches. - execFileSync(process.execPath, [JOURNAL_HOOK, "session-start"], { - input: JSON.stringify({ session_id: "not-a-known-host", cwd: projectDir }), - cwd: projectDir, - env: environmentWithoutGitVariables(process.env), - }); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch(/matched no known host/u); - expect(result.stdout).not.toMatch(/never been observed firing/u); - } finally { - await cleanup(); - } - }); -}); - -async function writeDamagedIdentity(fakeHome: string): Promise { - const path = identityFileIn(fakeHome); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, "not-json"); -} - -async function writeEnabledPlugin(projectDir: string, pluginKey: string): Promise { - await mkdir(join(projectDir, ".claude"), { recursive: true }); - await writeFile( - join(projectDir, ".claude", "settings.json"), - JSON.stringify({ enabledPlugins: { [pluginKey]: true } }) - ); -} - -// A hooks block a headless CI, or `aidd framework build --target claude --flat`'s own -// output, can declare directly — never through `enabledPlugins` at all. -async function writeClaudeHooksBlock(projectDir: string, command: string): Promise { - await mkdir(join(projectDir, ".claude"), { recursive: true }); - await writeFile( - join(projectDir, ".claude", "settings.json"), - JSON.stringify({ hooks: { SessionStart: [{ hooks: [{ type: "command", command }] }] } }) - ); -} - -// Cursor's own plugin-scope hooks never fire — this project-scope flat file is the only -// place a Cursor install's hook declaration is ever real. -async function writeCursorHooksBlock(projectDir: string, command: string): Promise { - await mkdir(join(projectDir, ".cursor"), { recursive: true }); - await writeFile( - join(projectDir, ".cursor", "hooks.json"), - JSON.stringify({ version: 1, hooks: { sessionStart: [{ command }] } }) - ); -} - -/** - * A machine that has never been measured still gets an answer, and a person switched - * off still sees everything but the verdicts: this half states, it never grades. - */ -describe("aidd telemetry check — what is in place, before any verdict", () => { - it("states what is in place on a machine that has never measured anything, naming the file behind each fact", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-fresh"); - try { - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/measurement allowed\s+no —/u); - expect(result.stdout).toContain(join(projectDir, ".aidd", "config.json")); - expect(result.stdout).toMatch(/identity attached\s+no —/u); - expect(result.stdout).toContain(identityFileIn(fakeHome)); - expect(result.stdout).toMatch(/records kept at\s+/u); - expect(result.stdout).toContain(sinkDirIn(fakeHome)); - expect(result.stdout).toMatch(/recorder declared\s+nowhere this build checks/u); - expect(result.stdout).toContain(join(projectDir, ".aidd", "manifest.json")); - // What is in place is never itself a verdict — no count, no figure, and no FAIL. - expect(result.stdout).not.toContain("FAIL"); - // Never reduced to the one-line gate message alone. - expect(result.stdout.trim().split("\n").length).toBeGreaterThan(1); - } finally { - await cleanup(); - } - }); - - it("distinguishes this person's own refusal from a project nobody switched on", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-refusal"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - - const refused = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { AIDD_TELEMETRY: "0" }, - }); - expect(refused.stdout).toMatch(/measurement allowed\s+no — this person's own refusal/u); - expect(refused.stdout).toContain("AIDD_TELEMETRY"); - - await writeSwitch(projectDir, false); - const neverOn = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(neverOn.stdout).toMatch(/measurement allowed\s+no —/u); - expect(neverOn.stdout).not.toMatch(/own refusal/u); - } finally { - await cleanup(); - } - }); - - it("keeps every other stated fact when the identity file cannot be read", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-damaged-identity"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeDamagedIdentity(fakeHome); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch(/identity attached\s+could not be read/u); - expect(result.stdout).toContain(identityFileIn(fakeHome)); - // Every other stated fact still appears — one damaged file costs only itself. - expect(result.stdout).toMatch(/measurement allowed\s+yes/u); - expect(result.stdout).toMatch(/records kept at\s+/u); - expect(result.stdout).toMatch(/recorder declared\s+/u); - expect(result.stdout).toContain("hook fired"); - } finally { - await cleanup(); - } - }); - - it("names where the recorder is declared, when a tool's own settings say so", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-declared"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeEnabledPlugin(projectDir, "aidd-telemetry@ai-driven-dev/framework"); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/recorder declared\s+yes —/u); - expect(result.stdout).toContain(join(projectDir, ".claude", "settings.json")); - } finally { - await cleanup(); - } - }); - - it("names where the recorder is declared, when a hooks block invokes it directly rather than through enabledPlugins", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-declared-hooks"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeClaudeHooksBlock( - projectDir, - `node ${CLAUDE_PLUGIN_ROOT_TOKEN}/hooks/journal.cjs session-start` - ); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/recorder declared\s+yes —/u); - expect(result.stdout).toContain(join(projectDir, ".claude", "settings.json")); - } finally { - await cleanup(); - } - }); - - it("names Cursor's project-scope hooks file as a declaration — the only route a Cursor install's hook ever fires from", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv( - "check-setup-declared-cursor-hooks" - ); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeCursorHooksBlock( - projectDir, - "./.cursor/hooks/aidd-telemetry/journal.cjs session-start" - ); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/recorder declared\s+yes —/u); - expect(result.stdout).toContain(join(projectDir, ".cursor", "hooks.json")); - } finally { - await cleanup(); - } - }); - - it("stops recognising a hooks block once it stops naming the recorder's own script, proving the match is not a loose substring", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-hooks-mutation"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeClaudeHooksBlock( - projectDir, - `node ${CLAUDE_PLUGIN_ROOT_TOKEN}/hooks/journal.cjs session-start` - ); - const declared = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(declared.stdout).toMatch(/recorder declared\s+yes —/u); - - await writeClaudeHooksBlock( - projectDir, - `node ${CLAUDE_PLUGIN_ROOT_TOKEN}/hooks/unrelated.cjs session-start` - ); - const undeclared = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(undeclared.stdout).toMatch(/recorder declared\s+nowhere this build checks/u); - } finally { - await cleanup(); - } - }); - - it("says the declaration could not be read, never that the recorder is missing, for a damaged declaring file", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv( - "check-setup-declaration-damaged" - ); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await mkdir(join(projectDir, ".claude"), { recursive: true }); - // Trailing comma — valid enabledPlugins content, invalid JSON. - await writeFile( - join(projectDir, ".claude", "settings.json"), - '{"enabledPlugins":{"aidd-telemetry@aidd":true},}' - ); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/recorder declared\s+could not be read —/u); - expect(result.stdout).toContain(join(projectDir, ".claude", "settings.json")); - // Never graded as the recorder missing: a damaged file costs only itself. - expect(result.stdout).not.toMatch(/recorder declared\s+nowhere this build checks/u); - expect(result.stdout).toMatch(/hook fired\s+--\s+no run file/u); - expect(result.stdout).not.toContain("FAIL"); - } finally { - await cleanup(); - } - }); - - it("never reads another plugin's own journal.cjs as this recorder being declared", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-foreign-journal"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeClaudeHooksBlock(projectDir, "node /autre-plugin/hooks/journal.cjs session-start"); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch(/recorder declared\s+nowhere this build checks/u); - } finally { - await cleanup(); - } - }); - - it("still recognises the recorder's own hooks-block entry point when the command is quoted", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-quoted-hooks"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeClaudeHooksBlock( - projectDir, - `node "${CLAUDE_PLUGIN_ROOT_TOKEN}/hooks/journal.cjs" session-start` - ); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/recorder declared\s+yes —/u); - } finally { - await cleanup(); - } - }); - - it("names Copilot's own settings file as a declaration route, not one this build never reads", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-setup-declared-copilot"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await mkdir(join(projectDir, ".github", "copilot"), { recursive: true }); - await writeFile( - join(projectDir, ".github", "copilot", "settings.json"), - JSON.stringify({ enabledPlugins: { "aidd-telemetry@ai-driven-dev/framework": true } }) - ); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/recorder declared\s+yes —/u); - expect(result.stdout).toContain(join(projectDir, ".github", "copilot", "settings.json")); - } finally { - await cleanup(); - } - }); -}); - -/** - * The same absence — no run file — reads two different ways depending on whether the - * recorder is declared. Proven by mutation on one project, never two fixtures. - */ -describe("aidd telemetry check — not yet stops being a failure", () => { - it("reports nothing to evaluate, never a failure, once the recorder is declared — and a failure naming it before that", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-not-yet-mutation"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - - const beforeDeclaring = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(beforeDeclaring.exitCode, beforeDeclaring.stderr).toBe(1); - expect(beforeDeclaring.stdout).toMatch(/hook fired\s+FAIL\s+no run file/u); - expect(beforeDeclaring.stdout).toMatch(/recorder is declared nowhere/u); - - await writeEnabledPlugin(projectDir, "aidd-telemetry@ai-driven-dev/framework"); - - const afterDeclaring = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(afterDeclaring.exitCode, afterDeclaring.stderr).toBe(0); - expect(afterDeclaring.stdout).toMatch(/hook fired\s+--\s+no run file/u); - expect(afterDeclaring.stdout).toMatch(/nothing to evaluate/u); - expect(afterDeclaring.stdout).toMatch(/declaration is not proof/u); - expect(afterDeclaring.stdout).not.toContain("FAIL"); - } finally { - await cleanup(); - } - }); - - it("keeps the verdict a run file already earned, whatever the recorder's own declaration says", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("check-not-yet-settled-stays"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - // No settings, no manifest — the recorder is declared nowhere — yet a run file - // already exists for this very session, so the claim it earns is "ok". - await seedJournal( - projectDir, - CLAUDE_RUN_ID, - CLAUDE_SESSION, - "2026-08-05T19:00:00Z", - "2026-08-05T20:00:00Z" - ); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CODEX_THREAD_ID: "", CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, - }); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch(/hook fired\s+ok/u); - expect(result.stdout).not.toMatch(/recorder is declared nowhere/u); - } finally { - await cleanup(); - } - }); - - it("keeps the verdict an anchorless run file already earned once the recorder is declared, never nothing to evaluate", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv( - "check-not-yet-anchorless-declared" - ); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await writeEnabledPlugin(projectDir, "aidd-telemetry@ai-driven-dev/framework"); - // Reachable, not synthetic: a hooks block registering PostToolUse/Stop but never - // SessionStart writes exactly this shape — journal lines with no session_start. - await seedTornRunFile(projectDir); - - const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(1); - expect(result.stdout).toMatch(/hook fired\s+FAIL\s+1 run file\(s\)/u); - expect(result.stdout).toMatch(/none carry a readable session_start/u); - expect(result.stdout).not.toMatch(/nothing to evaluate/u); - expect(result.stdout).not.toMatch(/no run file in/u); - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/telemetry-commit-trailer.e2e.test.ts b/cli/tests/e2e/telemetry-commit-trailer.e2e.test.ts deleted file mode 100644 index 0ff4b8b77..000000000 --- a/cli/tests/e2e/telemetry-commit-trailer.e2e.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { execFile, execFileSync } from "node:child_process"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; -import { SESSION_TRAILER_TOKEN } from "../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; -import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; -import { cliPath, pathWithoutAidd } from "./helpers.js"; - -const execFileAsync = promisify(execFile); - -/** - * Real commits, read back with `git log`: an install written to a directory git ignores - * reports success while the hook never runs, and only git itself can tell the two apart. - */ -const SESSION = "33333333-3333-4333-8333-333333333333"; -const OTHER_SESSION = "44444444-4444-4444-8444-444444444444"; - -/** Every variable `session-anchor.ts` reads, removed: the runner's own environment already - * carries one, so "no session made this commit" has to be stated to the spawned process. */ -function withoutSessionVariables(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const { CODEX_THREAD_ID, CLAUDE_CODE_SESSION_ID, ...rest } = env; - return rest; -} - -interface Repo { - readonly dir: string; - readonly aidd: (args: readonly string[]) => Promise<{ stdout: string }>; - /** Passing no session variables is how a commit nobody's session made gets written. */ - readonly commit: (message: string, sessionEnv?: NodeJS.ProcessEnv) => Promise; - readonly git: (args: readonly string[], sessionEnv?: NodeJS.ProcessEnv) => Promise; - readonly messageOf: (ref: string) => Promise; -} - -/** A repository per test rather than one shared through a hook: these run concurrently, and - * one test's cleanup would pull the ground out from under another. */ -async function withRepo(use: (repo: Repo) => Promise): Promise { - const tempDir = await mkdtemp(join(tmpdir(), "aidd-commit-trailer-")); - const dir = join(tempDir, "project"); - try { - execFileSync("git", ["init", "-q", dir], { - env: environmentWithoutGitVariables(process.env), - }); - - // No `aidd` on PATH: the hook stands on a shell and git alone. Both session variables are - // stripped first, since this suite may itself run inside a real session of its own. - const env = (extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv => ({ - ...withoutSessionVariables(environmentWithoutGitVariables(process.env)), - PATH: pathWithoutAidd(), - HOME: join(tempDir, "home"), - AIDD_USER_CONFIG_DIR: join(tempDir, "config"), - GIT_AUTHOR_NAME: "T", - GIT_AUTHOR_EMAIL: "t@example.com", - GIT_COMMITTER_NAME: "T", - GIT_COMMITTER_EMAIL: "t@example.com", - ...extra, - }); - - const git = async (args: readonly string[], sessionEnv: NodeJS.ProcessEnv = {}) => { - const { stdout } = await execFileAsync("git", [...args], { cwd: dir, env: env(sessionEnv) }); - return stdout; - }; - - await use({ - dir, - aidd: (args) => - execFileAsync(process.execPath, [cliPath(), ...args], { cwd: dir, env: env() }), - commit: async (message, sessionEnv = {}) => { - await writeFile(join(dir, `${message}.txt`), `${message}\n`); - await git(["add", "-A"], sessionEnv); - await git(["commit", "-q", "-m", message], sessionEnv); - }, - git, - messageOf: (ref) => git(["log", "-1", "--format=%B", ref]), - }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } -} - -describe.concurrent("a commit names the session that made it", () => { - it("carries the session's own identifier once measurement is on", async () => { - await withRepo(async (repo) => { - await repo.aidd(["telemetry", "on", "--yes"]); - - await repo.commit("first", { CLAUDE_CODE_SESSION_ID: SESSION }); - - expect(await repo.messageOf("HEAD")).toContain(`${SESSION_TRAILER_TOKEN}: ${SESSION}`); - }); - }); - - it("carries nothing when no session made the commit", async () => { - await withRepo(async (repo) => { - await repo.aidd(["telemetry", "on", "--yes"]); - - await repo.commit("by-hand"); - - expect(await repo.messageOf("HEAD")).not.toContain(SESSION_TRAILER_TOKEN); - }); - }); - - it("carries nothing at all until measurement is turned on", async () => { - await withRepo(async (repo) => { - await repo.commit("before", { CLAUDE_CODE_SESSION_ID: SESSION }); - - expect(await repo.messageOf("HEAD")).not.toContain(SESSION_TRAILER_TOKEN); - }); - }); - - // Codex nested inside a Claude Code session inherits the outer session's variable. A - // commit belongs to the process that made it, never to the one that launched it. - it("names the session actually running, not the one whose variable it inherited", async () => { - await withRepo(async (repo) => { - await repo.aidd(["telemetry", "on", "--yes"]); - - await repo.commit("nested", { - CLAUDE_CODE_SESSION_ID: OTHER_SESSION, - CODEX_THREAD_ID: SESSION, - }); - - const message = await repo.messageOf("HEAD"); - expect(message).toContain(`${SESSION_TRAILER_TOKEN}: ${SESSION}`); - expect(message).not.toContain(OTHER_SESSION); - }); - }); - - it("writes it once, not twice, when the commit is amended", async () => { - await withRepo(async (repo) => { - await repo.aidd(["telemetry", "on", "--yes"]); - await repo.commit("amended", { CLAUDE_CODE_SESSION_ID: SESSION }); - - await repo.git(["commit", "-q", "--amend", "--no-edit"], { - CLAUDE_CODE_SESSION_ID: SESSION, - }); - - const message = await repo.messageOf("HEAD"); - expect(message.split(SESSION_TRAILER_TOKEN).length - 1).toBe(1); - }); - }); - - it("stops trailering after off, and leaves the commits already made alone", async () => { - await withRepo(async (repo) => { - await repo.aidd(["telemetry", "on", "--yes"]); - await repo.commit("measured", { CLAUDE_CODE_SESSION_ID: SESSION }); - - await repo.aidd(["telemetry", "off"]); - await repo.commit("after-off", { CLAUDE_CODE_SESSION_ID: SESSION }); - - expect(await repo.messageOf("HEAD")).not.toContain(SESSION_TRAILER_TOKEN); - expect(await repo.messageOf("HEAD~1")).toContain(`${SESSION_TRAILER_TOKEN}: ${SESSION}`); - }); - }); - - it("says what it did, so nobody finds a trailer in their history unannounced", async () => { - await withRepo(async (repo) => { - const { stdout } = await repo.aidd(["telemetry", "on", "--yes"]); - - expect(stdout).toContain(SESSION_TRAILER_TOKEN); - expect(stdout).toContain("aidd telemetry off"); - }); - }); - - it("keeps a hook the repository already ran, and commits still succeed", async () => { - await withRepo(async (repo) => { - const hook = join(repo.dir, ".git", "hooks", "prepare-commit-msg"); - await writeFile(hook, '#!/bin/sh\necho "theirs ran" >> "$(dirname "$1")/theirs.log"\n'); - // `fs.chmod`, never a spawned `chmod`: on Windows that binary is Git's own and may be - // off PATH, while git runs a hook there through its shell, not through the file's mode. - await chmod(hook, 0o755); - await repo.aidd(["telemetry", "on", "--yes"]); - - await repo.commit("both-hooks", { CLAUDE_CODE_SESSION_ID: SESSION }); - - expect(await repo.messageOf("HEAD")).toContain(`${SESSION_TRAILER_TOKEN}: ${SESSION}`); - expect(await readFile(join(repo.dir, ".git", "theirs.log"), "utf8")).toContain("theirs ran"); - }); - }); -}); diff --git a/cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts b/cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts deleted file mode 100644 index abce4a623..000000000 --- a/cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { cp, mkdir } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { createTestEnv, runCli } from "./helpers.js"; - -/** Two different failures are guarded here: an answer that changed when the plugin's own copy - * of the report was deleted, and a command the skill names that the CLI never accepts. */ -const REPO_ROOT = REPOSITORY_ROOT; -const FIXTURE = resolve(process.cwd(), "tests/fixtures/cli-owns-read"); -const SKILL_DIR = join(REPO_ROOT, "plugins", "aidd-telemetry", "skills", "01-cost"); -const PERIOD = ["--from", "2026-01-01", "--to", "2026-01-31"]; - -function expectedEnvelope(): unknown { - return JSON.parse(readFileSync(join(FIXTURE, "expected-envelope.json"), "utf8")); -} - -/** Every `aidd telemetry …` command the skill's own markdown tells an agent to run. */ -function commandsNamedBySkill(): string[] { - const found = new Set(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith(".md")) { - for (const match of readFileSync(full, "utf8").matchAll(/`(aidd telemetry [^`]+)`/gu)) { - found.add(match[1] ?? ""); - } - } - } - }; - walk(SKILL_DIR); - return [...found]; -} - -/** A placeholder stands for a choice the agent makes, expanded by its own shape rather than - * by a copy of one skill's list, so the skill can name a new axis without editing this. */ -function runnable(command: string): string[] { - return command - .replace(/<([^<>|]+)(?:\|[^<>]+)>/gu, "$1") - .replace(//gu, "step") - .replace(//gu, "2026-01-01") - .split(/\s+/u) - .slice(1); -} - -async function sinkedEnv(): Promise<{ projectDir: string; fakeHome: string }> { - const { projectDir, fakeHome } = await createTestEnv("aidd-cost-skill-"); - const sink = join(fakeHome, ".config", "aidd", "telemetry"); - await mkdir(sink, { recursive: true }); - await cp(join(FIXTURE, "telemetry"), sink, { recursive: true }); - return { projectDir, fakeHome }; -} - -describe("E2E: 01-cost answers through the CLI", () => { - it("the envelope is what the deleted script produced, field for field", async () => { - const { projectDir, fakeHome } = await sinkedEnv(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toEqual(expectedEnvelope()); - }); - - it("the fixture is not vacuous, so this pin cannot pass on emptiness", () => { - const envelope = expectedEnvelope() as { - totals: { requests: number }; - by_step: readonly { step?: string }[]; - }; - - expect(envelope.totals.requests).toBeGreaterThan(0); - expect(new Set(envelope.by_step.map((row) => row.step)).size).toBeGreaterThanOrEqual(2); - }); - - it("every command the skill names is one the CLI accepts", async () => { - const { projectDir, fakeHome } = await sinkedEnv(); - const commands = commandsNamedBySkill(); - - // A closure test over an empty extraction passes vacuously. - expect(commands.length).toBeGreaterThanOrEqual(3); - - for (const command of commands) { - const result = await runCli(runnable(command), projectDir, fakeHome); - expect(result.exitCode, `${command}\n${result.stderr}`).toBe(0); - } - }); - - it("names no script beside itself any more", () => { - const named = new Set(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith(".md") && /\.cjs\b/u.test(readFileSync(full, "utf8"))) { - named.add(entry.name); - } - } - }; - walk(SKILL_DIR); - - expect([...named]).toEqual([]); - }); -}); diff --git a/cli/tests/e2e/telemetry-flow-axis.e2e.test.ts b/cli/tests/e2e/telemetry-flow-axis.e2e.test.ts deleted file mode 100644 index 737e100f4..000000000 --- a/cli/tests/e2e/telemetry-flow-axis.e2e.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FBY"; -const VENDOR_ID = "66666666-6666-4666-8666-666666666666"; -// A second session with no run journal on disk at all - the shape a session resumed after its -// context was compacted leaves: no `step_start` fires, while the transcript still states the step. -const NO_JOURNAL_VENDOR_ID = "77777777-7777-4777-8777-777777777777"; -const PROJECT_ID = "acme/widgets"; -const PERIOD = ["--from", "2026-03-01", "--to", "2026-03-31"]; - -// One session running the same orchestrating skill twice, with a hand-run skill inside the -// first run, and work before either ever opens. -const JOURNAL_LINES = [ - { - type: "session_start", - at: "2026-03-10T09:00:00Z", - run_id: RUN_ID, - tool: "codex", - vendor_id: VENDOR_ID, - project_id: PROJECT_ID, - }, - { type: "step_start", at: "2026-03-10T09:20:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-03-10T09:30:00Z", skill: "aidd-dev:02-implement" }, - { type: "turn_end", at: "2026-03-10T09:50:00Z" }, - { type: "step_start", at: "2026-03-10T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "turn_end", at: "2026-03-10T10:30:00Z" }, -]; - -function record(overrides: Record): Record { - return { - kind: "request", - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - vendor_id: VENDOR_ID, - vendor_field: "session_meta.id", - step_attribution: "unattributed", - project_id: PROJECT_ID, - ...overrides, - }; -} - -const RECORDS = [ - // Before the first orchestrating step opens - outside every flow. - record({ turn_id: "before-any-flow", event_timestamp: "2026-03-10T09:10:00Z", cost_usd: 1 }), - // Inside the first sdlc run, from the orchestrator's own step_start. - record({ - turn_id: "first-run-orchestrator", - event_timestamp: "2026-03-10T09:25:00Z", - cost_usd: 2, - }), - // Inside the first sdlc run, but from the hand-run skill - the journal cannot tell it apart. - record({ turn_id: "first-run-hand-run", event_timestamp: "2026-03-10T09:35:00Z", cost_usd: 3 }), - // After the turn ended, before the next orchestrating step opens - still the first sdlc run, - // which a pause does not end. The separating record for this axis. - record({ - turn_id: "first-run-after-pause", - event_timestamp: "2026-03-10T09:55:00Z", - cost_usd: 5, - }), - // Inside the second, distinct sdlc run. - record({ turn_id: "second-run", event_timestamp: "2026-03-10T10:10:00Z", cost_usd: 4 }), - // A session whose journal opened no flow, whose own tool named the orchestrating skill. - record({ - vendor_id: NO_JOURNAL_VENDOR_ID, - turn_id: "stated-one", - event_timestamp: "2026-03-11T09:00:00Z", - cost_usd: 6, - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - }), - record({ - vendor_id: NO_JOURNAL_VENDOR_ID, - turn_id: "stated-two", - event_timestamp: "2026-03-11T10:00:00Z", - cost_usd: 7, - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - }), -]; - -interface FlowRow { - readonly flow?: string; - readonly attribution: string; - readonly started_at?: string; - readonly totals: { readonly requests: number; readonly cost_micro_usd?: number }; -} - -interface Envelope { - readonly cost_report_version: number; - readonly totals: { readonly requests: number; readonly cost_micro_usd?: number }; - readonly by_flow: readonly FlowRow[]; -} - -describe("aidd telemetry report — by_flow through the real adapter, on real disk", () => { - let cleanup: (() => Promise) | undefined; - - afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - }); - - async function seed(): Promise<{ projectDir: string; fakeHome: string }> { - const env = await createTestEnv("telemetry-flow-axis"); - cleanup = env.cleanup; - await gitInit(env.projectDir); - await mkdir(join(env.projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(env.projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - "utf-8" - ); - const runsDir = join(env.projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await writeFile( - join(runsDir, `${RUN_ID}__${VENDOR_ID}.jsonl`), - `${JOURNAL_LINES.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8" - ); - const sinkDir = join(env.fakeHome, ".config", "aidd", "telemetry"); - await mkdir(sinkDir, { recursive: true }); - await writeFile( - join(sinkDir, "2026-03-31.jsonl"), - `${RECORDS.map((r) => JSON.stringify(r)).join("\n")}\n`, - "utf-8" - ); - return { projectDir: env.projectDir, fakeHome: env.fakeHome }; - } - - it("gives the same orchestrating skill run twice in one session two rows, never merged into one", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - const envelope = JSON.parse(result.stdout) as Envelope; - - // Only the runs the journal itself witnessed: the row a second session's own tool named - // shares the skill's name and is a different claim, told apart by `attribution`. - const sdlcRuns = envelope.by_flow.filter( - (row) => row.flow === "aidd-orchestrator:01-sdlc" && row.attribution === "journal-interval" - ); - expect(sdlcRuns).toHaveLength(2); - expect(new Set(sdlcRuns.map((row) => row.started_at)).size).toBe(2); - // The first run holds both the orchestrator's own record and the hand-run skill's - - // the journal cannot tell them apart, so both count inside it. - const firstRun = sdlcRuns.find((row) => row.started_at === "2026-03-10T09:20:00Z"); - expect(firstRun?.totals.requests).toBe(3); - expect(firstRun?.totals.cost_micro_usd).toBe(10_000_000); - const secondRun = sdlcRuns.find((row) => row.started_at === "2026-03-10T10:00:00Z"); - expect(secondRun?.totals.requests).toBe(1); - expect(secondRun?.totals.cost_micro_usd).toBe(4_000_000); - }); - - it("gives work before the first orchestrating step its own row, outside any flow", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - const envelope = JSON.parse(result.stdout) as Envelope; - - const outside = envelope.by_flow.find((row) => row.flow === undefined); - expect(outside?.totals.requests).toBe(1); - expect(outside?.totals.cost_micro_usd).toBe(1_000_000); - }); - - it("keeps a record made after the turn ended inside the flow that was still running", async () => { - // A `turn_end` is a pause, not the end of an orchestration. This record sits between the - // pause at 09:50 and the next orchestrating step at 10:00, where the two rules differ. - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - const envelope = JSON.parse(result.stdout) as Envelope; - - const firstRun = envelope.by_flow.find((row) => row.started_at === "2026-03-10T09:20:00Z"); - expect(firstRun?.totals.cost_micro_usd).toBe(10_000_000); // 2 + 3 + 5, the record at 09:55 included - const outside = envelope.by_flow.find((row) => row.flow === undefined); - expect(outside?.totals.cost_micro_usd).toBe(1_000_000); // the 09:10 record alone - }); - - it("reconciles by_flow to the same total as the period", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - const envelope = JSON.parse(result.stdout) as Envelope; - - const sum = envelope.by_flow.reduce((total, row) => total + row.totals.requests, 0); - expect(sum).toBe(envelope.totals.requests); - // outside-flow row + two distinct sdlc runs + the run only the tool named - expect(envelope.by_flow).toHaveLength(4); - }); - - it("prints the flow axis through --axis, naming both runs and the outside-flow row", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli( - ["telemetry", "report", ...PERIOD, "--axis", "flow"], - projectDir, - fakeHome - ); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("axis: by flow"); - expect(result.stdout).toContain("aidd-orchestrator:01-sdlc"); - expect(result.stdout).toContain("outside any flow"); - // The table's own shape, not only the names in it: this artefact exists to be pasted, - // so a column added or dropped is a break for whatever reads the paste. - expect(result.stdout).toContain("| Flow | Attribution | Opened at | Total |"); - // A tool-stated row prints its attribution and an em dash where a run would name its - // opening moment. - expect(result.stdout).toContain("| aidd-orchestrator:01-sdlc | stated by the tool | — |"); - }); - - it("states the limit that belongs to each kind of flow row, and no other", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli( - ["telemetry", "report", ...PERIOD, "--axis", "flow"], - projectDir, - fakeHome - ); - - // This period holds both kinds, so both sets of limits apply. - expect(result.stdout).toContain("a skill run by hand while a flow was open"); - expect(result.stdout).toContain("is every run of that skill at once"); - }); - - it("names the flow a session's own tool stated, where its journal opened none", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - const envelope = JSON.parse(result.stdout) as Envelope; - - const stated = envelope.by_flow.find((row) => row.attribution === "tool-stated"); - expect(stated?.flow).toBe("aidd-orchestrator:01-sdlc"); - expect(stated?.totals.cost_micro_usd).toBe(13_000_000); // 6 + 7 - // A name is not a run: the row is a bucket drawn from however many runs the tool named. - expect(stated?.started_at).toBeUndefined(); - const witnessed = envelope.by_flow.filter((row) => row.attribution === "journal-interval"); - expect(witnessed).toHaveLength(2); - }); -}); diff --git a/cli/tests/e2e/telemetry-forget.e2e.test.ts b/cli/tests/e2e/telemetry-forget.e2e.test.ts deleted file mode 100644 index 4d96c55cf..000000000 --- a/cli/tests/e2e/telemetry-forget.e2e.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; -import { createTestEnv, gitInit, identityFileIn, runCli, sinkDirIn } from "./helpers.js"; - -const execFileAsync = promisify(execFile); - -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; -const VENDOR_ID = "vendorabc"; -const JOURNAL_FILE = `${RUN_ID}__${VENDOR_ID}.jsonl`; - -async function git(args: readonly string[], cwd: string): Promise<{ stdout: string }> { - return execFileAsync("git", [...args], { cwd, env: environmentWithoutGitVariables(process.env) }); -} - -async function seedJournal(projectDir: string): Promise { - const runsDir = join(projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - const line = (value: unknown) => `${JSON.stringify(value)}\n`; - await writeFile( - join(runsDir, JOURNAL_FILE), - line({ - type: "session_start", - at: "2026-08-20T10:00:00.000Z", - run_id: RUN_ID, - tool: "claude-code", - vendor_id: VENDOR_ID, - project_id: "acme-widgets", - }) + line({ type: "turn_end", at: "2026-08-20T10:05:00.000Z" }) - ); - return join(runsDir, JOURNAL_FILE); -} - -async function seedSinkDayFile( - fakeHome: string, - dayFile: string, - content: string -): Promise { - const dir = sinkDirIn(fakeHome); - await mkdir(dir, { recursive: true }); - const filePath = join(dir, dayFile); - await writeFile(filePath, content); - return filePath; -} - -function sinkRecordLine(vendorId: string): string { - return `${JSON.stringify({ - sink_schema_version: 2, - kind: "request", - provenance: "export", - tool: "claude", - vendor_id: vendorId, - vendor_field: "session.id", - cost_usd: 1, - step_attribution: "unattributed", - })}\n`; -} - -async function seedIdentity(fakeHome: string, personId: string): Promise { - const filePath = identityFileIn(fakeHome); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, `${JSON.stringify({ person_id: personId, origin: "minted" })}\n`); - return filePath; -} - -async function writeSwitch(projectDir: string, enabled: boolean): Promise { - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled } }) - ); -} - -async function readSwitch(projectDir: string): Promise { - return readFile(join(projectDir, ".aidd", "config.json"), "utf8"); -} - -async function entries(dir: string): Promise { - try { - return await readdir(dir); - } catch { - return []; - } -} - -describe("aidd telemetry forget — shows, confirms, removes, and names what history keeps", () => { - it("a machine where nothing was ever measured has nothing to remove, and offers nothing", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-empty"); - try { - await gitInit(projectDir); - - const result = await runCli(["telemetry", "forget"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/nothing was ever measured/u); - expect(result.stdout).not.toMatch(/Pass --yes/u); - } finally { - await cleanup(); - } - }); - - it("previews an untracked journal as history possibly holding it, never as an all-clear", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-untracked-preview"); - try { - await gitInit(projectDir); - await seedJournal(projectDir); - await seedSinkDayFile(fakeHome, "2026-08-20.jsonl", sinkRecordLine("s-1")); - await seedIdentity(fakeHome, "11111111-1111-1111-1111-111111111111"); - - const result = await runCli(["telemetry", "forget"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/This project's run journal/u); - expect(result.stdout).toMatch(/1 run file/u); - expect(result.stdout).toMatch(/This machine's stored records — every project/u); - expect(result.stdout).toMatch(/1 day file/u); - expect(result.stderr).toMatch(/may still hold it if it was ever committed before/u); - expect(result.stderr).not.toMatch(/certainly holds it/u); - } finally { - await cleanup(); - } - }); - - it("previews a tracked journal as history certainly holding it, naming the file", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-tracked-preview"); - try { - await gitInit(projectDir); - await seedJournal(projectDir); - const trackedRelative = `aidd_docs/runs/${JOURNAL_FILE}`; - await git(["add", trackedRelative], projectDir); - await git( - [ - "-c", - "user.email=t@t.com", - "-c", - "user.name=t", - "commit", - "-q", - "-m", - "committed by hand", - ], - projectDir - ); - - const result = await runCli(["telemetry", "forget"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stderr).toMatch(/certainly holds it/u); - expect(result.stderr).toContain(trackedRelative); - expect(result.stderr).toMatch(/does not remove it from history/u); - } finally { - await cleanup(); - } - }); - - // `git ls-files` reads the index, not history: a staged journal in a repository with no - // commits is not held by history at all. - it("previews a staged-but-never-committed journal honestly, never as certainly held", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-staged-preview"); - try { - await gitInit(projectDir); - await seedJournal(projectDir); - const trackedRelative = `aidd_docs/runs/${JOURNAL_FILE}`; - await git(["add", trackedRelative], projectDir); - // Deliberately no commit — this repository has zero commits at all. - - const result = await runCli(["telemetry", "forget"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stderr).not.toMatch(/certainly holds it/u); - expect(result.stderr).toMatch(/has never been committed/u); - expect(result.stderr).toContain(trackedRelative); - } finally { - await cleanup(); - } - }); - - it("a relocated AIDD_RUNS_DIR touches only the relocated location, never the project's own runs dir", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-runs-relocated"); - try { - await gitInit(projectDir); - const realJournalPath = await seedJournal(projectDir); - - const relocated = join(fakeHome, "relocated-runs"); - await mkdir(relocated, { recursive: true }); - await writeFile(join(relocated, JOURNAL_FILE), "relocated content\n"); - - const result = await runCli(["telemetry", "forget", "--yes"], projectDir, fakeHome, { - env: { AIDD_RUNS_DIR: relocated }, - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain(relocated); - expect(await entries(relocated)).toEqual([]); - expect(await readFile(realJournalPath, "utf8")).toContain(VENDOR_ID); - } finally { - await cleanup(); - } - }); - - // A directory named `*.jsonl` refuses removal portably — `EISDIR` on POSIX, - // access-denied-shaped on Windows — with no `chmod` needed. - it("a run file that refuses removal (a directory named *.jsonl) is reported, and the rest is still removed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-refused-real"); - try { - await gitInit(projectDir); - const runsDir = join(projectDir, "aidd_docs", "runs"); - await mkdir(join(runsDir, "adir.jsonl"), { recursive: true }); - await seedSinkDayFile(fakeHome, "2026-08-20.jsonl", sinkRecordLine("s-1")); - await seedIdentity(fakeHome, "11111111-1111-1111-1111-111111111111"); - - const result = await runCli(["telemetry", "forget", "--yes"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch( - /This project's run journal: 0 removed, 1 could not be removed/u - ); - expect(result.stderr).toMatch(/Could not remove journal run file adir\.jsonl/u); - expect(await entries(runsDir)).toEqual(["adir.jsonl"]); - expect(result.stdout).toMatch(/This machine's stored records: 1 removed/u); - expect(result.stdout).toMatch(/This machine's identity: 1 removed/u); - expect(await entries(sinkDirIn(fakeHome))).toEqual([]); - await expect(readFile(identityFileIn(fakeHome), "utf8")).rejects.toThrow(); - } finally { - await cleanup(); - } - }); - - it("without --yes, refuses: nothing removed, and it says so plainly, exiting successfully", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-refused"); - try { - await gitInit(projectDir); - const journalPath = await seedJournal(projectDir); - const sinkPath = await seedSinkDayFile(fakeHome, "2026-08-20.jsonl", sinkRecordLine("s-1")); - const identityPath = await seedIdentity(fakeHome, "11111111-1111-1111-1111-111111111111"); - - const result = await runCli(["telemetry", "forget"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/Nothing removed\. Pass --yes/u); - expect(await readFile(journalPath, "utf8")).toContain(VENDOR_ID); - expect(await readFile(sinkPath, "utf8")).toContain("s-1"); - expect(await readFile(identityPath, "utf8")).toContain("11111111"); - } finally { - await cleanup(); - } - }); - - it("with --yes, removes exactly what was shown, in counts that match, and leaves the switch alone", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-confirmed"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await seedJournal(projectDir); - await seedSinkDayFile(fakeHome, "2026-08-19.jsonl", sinkRecordLine("s-1")); - await seedSinkDayFile(fakeHome, "2026-08-20.jsonl", sinkRecordLine("s-2")); - await seedIdentity(fakeHome, "11111111-1111-1111-1111-111111111111"); - - const preview = await runCli(["telemetry", "forget"], projectDir, fakeHome); - expect(preview.stdout).toMatch(/1 run file/u); - expect(preview.stdout).toMatch(/2 day file/u); - - const result = await runCli(["telemetry", "forget", "--yes"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/This project's run journal: 1 removed/u); - expect(result.stdout).toMatch(/This machine's stored records: 2 removed/u); - expect(result.stdout).toMatch(/This machine's identity: 1 removed/u); - expect(await entries(join(projectDir, "aidd_docs", "runs"))).toEqual([]); - expect(await entries(sinkDirIn(fakeHome))).toEqual([]); - await expect(readFile(identityFileIn(fakeHome), "utf8")).rejects.toThrow(); - - expect(JSON.parse(await readSwitch(projectDir))).toEqual({ telemetry: { enabled: true } }); - const onAgain = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - expect(onAgain.exitCode, onAgain.stderr).toBe(0); - } finally { - await cleanup(); - } - }); - - it("history is repeated after removing, not only before", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-history-after"); - try { - await gitInit(projectDir); - await seedJournal(projectDir); - const trackedRelative = `aidd_docs/runs/${JOURNAL_FILE}`; - await git(["add", trackedRelative], projectDir); - await git( - [ - "-c", - "user.email=t@t.com", - "-c", - "user.name=t", - "commit", - "-q", - "-m", - "committed by hand", - ], - projectDir - ); - - const result = await runCli(["telemetry", "forget", "--yes"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stderr).toMatch(/certainly holds it/u); - expect(result.stderr).toContain(trackedRelative); - } finally { - await cleanup(); - } - }); - - it("a damaged record file is removed and reported as removed, exactly like any other", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-damaged"); - try { - await gitInit(projectDir); - await seedSinkDayFile(fakeHome, "2026-08-20.jsonl", "not json at all {{{\n"); - - const result = await runCli(["telemetry", "forget", "--yes"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/This machine's stored records: 1 removed/u); - expect(await entries(sinkDirIn(fakeHome))).toEqual([]); - } finally { - await cleanup(); - } - }); - - it("a relocated AIDD_USER_CONFIG_DIR touches only the relocated location, never the real profile", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-relocated"); - try { - await gitInit(projectDir); - // A record under the ordinary profile, which the relocated run must never reach. - const realSinkPath = await seedSinkDayFile( - fakeHome, - "2026-08-18.jsonl", - sinkRecordLine("real") - ); - - const relocated = join(fakeHome, "relocated-config"); - await mkdir(join(relocated, "telemetry"), { recursive: true }); - await writeFile( - join(relocated, "telemetry", "2026-08-17.jsonl"), - sinkRecordLine("relocated") - ); - - const result = await runCli(["telemetry", "forget", "--yes"], projectDir, fakeHome, { - env: { AIDD_USER_CONFIG_DIR: relocated }, - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain(relocated); - expect(await entries(join(relocated, "telemetry"))).toEqual([]); - expect(await readFile(realSinkPath, "utf8")).toContain("real"); - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/telemetry-hook-install.e2e.test.ts b/cli/tests/e2e/telemetry-hook-install.e2e.test.ts deleted file mode 100644 index 6ef56274f..000000000 --- a/cli/tests/e2e/telemetry-hook-install.e2e.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { execFile } from "node:child_process"; -import { readdirSync, readFileSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { createTestEnv, gitInit, gitSetOriginRemote, runCli } from "./helpers.js"; - -const execFileAsync = promisify(execFile); -const REPO_ROOT = REPOSITORY_ROOT; -const PLUGIN_SOURCE = join(REPO_ROOT, "plugins", "aidd-telemetry"); -const SESSION_FIXTURE = join( - REPO_ROOT, - "scripts", - "__tests__", - "fixtures", - "claude-code-session-start.json" -); - -// Installation moves the journal hook, and a move dropping `hooks/lib/` leaves one that -// throws on its first require - silently, since a hook that fails never records. -describe("E2E: the journal hook runs from where installation puts it", () => { - it("records a session through the installed plugin, not the source tree", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("telemetry-hook-install"); - try { - await gitInit(projectDir); - await gitSetOriginRemote(projectDir, "git@github.com:aidd-lab/hook-install.git"); - expect( - (await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome)).exitCode - ).toBe(0); - expect( - (await runCli(["plugin", "install", PLUGIN_SOURCE, "--yes"], projectDir, fakeHome)).exitCode - ).toBe(0); - - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true, endpoint: "http://127.0.0.1:4318" } }) - ); - - const payload = JSON.parse(readFileSync(SESSION_FIXTURE, "utf-8")); - payload.cwd = projectDir; - payload.session_id = "9f1c2d34-aaaa-4bbb-8ccc-0000000000e2"; - - const hookPath = join( - projectDir, - ".claude", - "plugins", - "aidd-telemetry", - "hooks", - "journal.cjs" - ); - const hook = execFileAsync(process.execPath, [hookPath, "session-start"]); - hook.child.stdin?.end(JSON.stringify(payload)); - const { stderr } = await hook; - expect(stderr).toBe(""); - - const written = readdirSync(join(projectDir, "aidd_docs", "runs")); - expect(written).toHaveLength(1); - expect(written[0]).toMatch(/\.jsonl$/); - const lines = readFileSync( - join(projectDir, "aidd_docs", "runs", written[0] as string), - "utf-8" - ) - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line)); - expect(lines).toHaveLength(1); - const sessionStart = lines[0]; - expect(sessionStart.type).toBe("session_start"); - expect(sessionStart.schema_version).toBe(2); - expect(sessionStart.project_id).toBe("aidd-lab/hook-install"); - expect(sessionStart.project_remote).toBe("git@github.com:aidd-lab/hook-install.git"); - expect(sessionStart.vendor_id).toBe(payload.session_id); - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/telemetry-host-registration.e2e.test.ts b/cli/tests/e2e/telemetry-host-registration.e2e.test.ts deleted file mode 100644 index 20e856880..000000000 --- a/cli/tests/e2e/telemetry-host-registration.e2e.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { cliPath, pathWithoutAidd } from "./helpers.js"; - -const execFileAsync = promisify(execFile); - -/** On the built binary: plugins a project declares against a host registry carrying one of - * them and not the other, answerable with no tool on `PATH`, no network and no money. */ -const PLUGIN_SOURCE = { kind: "github", repo: "ai-driven-dev/framework" } as const; - -function pluginEntry(name: string, marketplace?: string) { - return { - name, - source: PLUGIN_SOURCE, - version: "1.0.0", - strict: true, - files: {}, - scope: "project", - ...(marketplace === undefined ? {} : { marketplace }), - }; -} - -describe("check says whether the host will load what aidd installed", () => { - let projectDir: string; - let fakeHome: string; - - beforeEach(async () => { - const root = await mkdtemp(join(tmpdir(), "aidd-host-registration-e2e-")); - projectDir = join(root, "project"); - fakeHome = join(root, "home"); - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await mkdir(join(fakeHome, ".claude", "plugins"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }) - ); - await writeFile( - join(projectDir, ".aidd", "manifest.json"), - JSON.stringify({ - version: 8, - tools: { - claude: { - files: [], - mergeFiles: [], - plugins: [ - pluginEntry("aidd-telemetry", "aidd-framework"), - pluginEntry("aidd-dev", "aidd-framework"), - pluginEntry("hand-copied"), - ], - }, - }, - }) - ); - // Only the first is registered, and it names this project — which is how `aidd` installs, - // at project scope (`native-plugin-cli-adapter.ts`'s own `--scope project`). - await writeFile( - join(fakeHome, ".claude", "plugins", "installed_plugins.json"), - JSON.stringify({ - version: 1, - plugins: { - "aidd-telemetry@aidd-framework": [{ scope: "project", projectPath: projectDir }], - }, - }) - ); - }); - - afterEach(async () => { - await rm(join(projectDir, ".."), { recursive: true, force: true }); - }); - - it("names each plugin's answer, with no AI tool on PATH and nothing to spend", async () => { - const { stdout } = await execFileAsync(process.execPath, [cliPath(), "telemetry", "check"], { - cwd: projectDir, - env: { - PATH: pathWithoutAidd(), - HOME: fakeHome, - AIDD_TELEMETRY_DIR: join(fakeHome, "telemetry"), - }, - }); - - expect(stdout).toContain("plugins registered"); - // Declared, and the host will drop it as orphaned. - expect(stdout).toContain("claude/aidd-dev: not-registered"); - // No marketplace recorded, so no registry keys on it — unanswerable, never "not there". - expect(stdout).toContain("claude/hand-copied: unanswerable"); - expect(stdout).toContain("claude/aidd-telemetry: registered"); - // Problem first: a person who reads one line reads the one they must act on. - expect(stdout.indexOf("aidd-dev")).toBeLessThan(stdout.indexOf("aidd-telemetry: registered")); - }); - - it("does not count a registration made for a different project", async () => { - await writeFile( - join(fakeHome, ".claude", "plugins", "installed_plugins.json"), - JSON.stringify({ - version: 1, - plugins: { - "aidd-telemetry@aidd-framework": [{ scope: "project", projectPath: "/somewhere/else" }], - }, - }) - ); - - const { stdout } = await execFileAsync(process.execPath, [cliPath(), "telemetry", "check"], { - cwd: projectDir, - env: { - PATH: pathWithoutAidd(), - HOME: fakeHome, - AIDD_TELEMETRY_DIR: join(fakeHome, "telemetry"), - }, - }); - - expect(stdout).toContain("claude/aidd-telemetry: not-registered"); - }); -}); diff --git a/cli/tests/e2e/telemetry-identity-resolution.e2e.test.ts b/cli/tests/e2e/telemetry-identity-resolution.e2e.test.ts deleted file mode 100644 index 87295b2f6..000000000 --- a/cli/tests/e2e/telemetry-identity-resolution.e2e.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, identityFileIn, runCli, sinkDirIn } from "./helpers.js"; - -/** - * Every seed writes straight into the sink: the subject here is resolution, not any one - * tool's reader, and `runCli` sandboxes `PATH` so no AI tool binary is reachable at all. - */ -const FROM_DAY = "2026-08-17"; -const TO_DAY = "2026-08-21"; -const REPORT_PERIOD = ["--from", FROM_DAY, "--to", TO_DAY] as const; -const MOMENT = "2026-08-18T10:00:00.000Z"; - -function record(overrides: Record): Record { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - vendor_field: "sessionId", - step_attribution: "unattributed", - event_timestamp: MOMENT, - ...overrides, - }; -} - -/** Where records actually land: `sinkDirIn(fakeHome)` for the profile-resolved sink, or - * `join(configDir, "telemetry")` for one relocated by `AIDD_USER_CONFIG_DIR`. */ -async function seedSink( - sinkDir: string, - records: readonly Record[] -): Promise { - await mkdir(sinkDir, { recursive: true }); - await writeFile( - join(sinkDir, "2026-08-18.jsonl"), - `${records.map((r) => JSON.stringify(r)).join("\n")}\n`, - "utf8" - ); -} - -/** Declares this machine's own identifier through the CLI, the way a real second machine - * would take an identity minted elsewhere - never by writing `identity.json` by hand. */ -async function useIdentity(projectDir: string, fakeHome: string, personId: string): Promise { - const result = await runCli(["telemetry", "identity", "use", personId], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); -} - -async function setUp(prefix: string) { - const env = await createTestEnv(prefix); - await gitInit(env.projectDir); - return env; -} - -interface PersonReportEnvelope { - by_person: Array<{ - resolution: string; - person?: string; - identities: readonly string[]; - totals: { requests: number }; - }>; - totals: { requests: number }; - read: { identity_unusable?: "unreadable" | "absent" }; -} - -async function reportJson( - projectDir: string, - fakeHome: string, - extraArgs: readonly string[] = [], - env?: Record -): Promise { - const result = await runCli( - ["telemetry", "report", ...REPORT_PERIOD, ...extraArgs, "--json"], - projectDir, - fakeHome, - env ? { env } : undefined - ); - expect(result.exitCode, result.stderr).toBe(0); - return JSON.parse(result.stdout); -} - -describe("aidd telemetry report --axis person, and the identity commands that feed it", () => { - let cleanup: (() => Promise) | undefined; - - afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - }); - - // The identity is machine-scoped, minted once per profile and shared by every tool that - // reads locally on it — never tool-scoped. - it("two tools under one identifier print one person row", async () => { - const { projectDir, fakeHome, cleanup: c } = await setUp("person-two-tools"); - cleanup = c; - await seedSink(sinkDirIn(fakeHome), [ - record({ tool: "claude", vendor_id: "s-claude", turn_id: "t-1", person_id: "shared-id" }), - record({ tool: "codex", vendor_id: "s-codex", turn_id: "t-2", person_id: "shared-id" }), - ]); - - const envelope = await reportJson(projectDir, fakeHome); - - const rows = envelope.by_person.filter((row) => row.identities.includes("shared-id")); - expect(rows).toHaveLength(1); - expect(rows[0]?.totals.requests).toBe(2); - }); - - it("a second machine's identifier prints unresolved before linking, and merges after", async () => { - const { projectDir, fakeHome, cleanup: c } = await setUp("person-two-machines"); - cleanup = c; - await useIdentity(projectDir, fakeHome, "person-a"); - await seedSink(sinkDirIn(fakeHome), [ - record({ tool: "claude", vendor_id: "s-1", turn_id: "t-1", person_id: "person-a" }), - record({ tool: "claude", vendor_id: "s-2", turn_id: "t-2", person_id: "machine-b-id" }), - ]); - - const before = await reportJson(projectDir, fakeHome); - expect(before.by_person).toHaveLength(2); - expect(before.by_person.some((row) => row.resolution === "unresolved")).toBe(true); - - // Taking the identifier already in effect reports it back rather than adopting a - // second time, the way "a second on reports the same identifier" pins for `on`. - const useAgain = await runCli( - ["telemetry", "identity", "use", "person-a"], - projectDir, - fakeHome - ); - expect(useAgain.exitCode, useAgain.stderr).toBe(0); - expect(useAgain.stdout).toMatch(/already in effect/iu); - const linkResult = await runCli( - ["telemetry", "identity", "link", "machine-b-id"], - projectDir, - fakeHome - ); - expect(linkResult.exitCode, linkResult.stderr).toBe(0); - - const after = await reportJson(projectDir, fakeHome); - const mapped = after.by_person.filter((row) => row.resolution === "mapped"); - expect(mapped).toHaveLength(1); - expect(mapped[0]?.identities).toEqual(expect.arrayContaining(["person-a", "machine-b-id"])); - expect(mapped[0]?.totals.requests).toBe(2); - - const unlinkResult = await runCli( - ["telemetry", "identity", "unlink", "machine-b-id"], - projectDir, - fakeHome - ); - expect(unlinkResult.exitCode, unlinkResult.stderr).toBe(0); - - const afterUnlink = await reportJson(projectDir, fakeHome); - expect(afterUnlink.by_person).toHaveLength(2); - const stillUnresolved = afterUnlink.by_person.find((row) => - row.identities.includes("machine-b-id") - ); - expect(stillUnresolved?.resolution).toBe("unresolved"); - }); - - it("sums every person row to the period total, and never merges two unmapped identifiers", async () => { - const { projectDir, fakeHome, cleanup: c } = await setUp("person-reconcile"); - cleanup = c; - await useIdentity(projectDir, fakeHome, "person-a"); - await runCli(["telemetry", "identity", "link", "machine-b-id"], projectDir, fakeHome); - await seedSink(sinkDirIn(fakeHome), [ - record({ tool: "claude", vendor_id: "s-1", turn_id: "t-1", person_id: "person-a" }), - record({ tool: "claude", vendor_id: "s-2", turn_id: "t-2", person_id: "machine-b-id" }), - record({ tool: "claude", vendor_id: "s-3", turn_id: "t-3", person_id: "a-stranger" }), - record({ tool: "claude", vendor_id: "s-4", turn_id: "t-4", person_id: "another-stranger" }), - record({ tool: "claude", vendor_id: "s-5", turn_id: "t-5" }), - ]); - - const envelope = await reportJson(projectDir, fakeHome); - - const sum = envelope.by_person.reduce((total, row) => total + row.totals.requests, 0); - expect(sum).toBe(envelope.totals.requests); - expect(envelope.totals.requests).toBe(5); - const unresolved = envelope.by_person.filter((row) => row.resolution === "unresolved"); - expect(unresolved).toHaveLength(2); - expect(new Set(unresolved.flatMap((row) => row.identities)).size).toBe(2); - }); - - it("identity lists every added identifier with no report ever run", async () => { - const { projectDir, fakeHome, cleanup: c } = await setUp("person-status-first"); - cleanup = c; - await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - await runCli(["telemetry", "identity", "link", "machine-b-id"], projectDir, fakeHome); - - const status = await runCli(["telemetry", "identity"], projectDir, fakeHome); - - expect(status.exitCode, status.stderr).toBe(0); - expect(status.stdout).toContain("machine-b-id"); - }); - - it("an identity that does not parse costs the resolution, never one figure", async () => { - const { projectDir, fakeHome, cleanup: c } = await setUp("person-corrupt-identity"); - cleanup = c; - const filePath = identityFileIn(fakeHome); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, "this is not json{{{", "utf8"); - await seedSink(sinkDirIn(fakeHome), [ - record({ tool: "claude", vendor_id: "s-1", turn_id: "t-1", person_id: "machine-1" }), - ]); - - const envelope = await reportJson(projectDir, fakeHome); - - expect(envelope.totals.requests).toBe(1); - expect(envelope.read.identity_unusable).toBe("unreadable"); - expect(envelope.by_person.every((row) => row.resolution !== "mapped")).toBe(true); - - const textResult = await runCli( - ["telemetry", "report", ...REPORT_PERIOD, "--axis", "person"], - projectDir, - fakeHome - ); - expect(textResult.exitCode, textResult.stderr).toBe(0); - expect(textResult.stdout).toMatch(/own identity could not be read/iu); - }); - - it("no identity declared at all still reports every figure, naming that cause", async () => { - const { projectDir, fakeHome, cleanup: c } = await setUp("person-no-identity"); - cleanup = c; - await seedSink(sinkDirIn(fakeHome), [ - record({ tool: "claude", vendor_id: "s-1", turn_id: "t-1", person_id: "machine-1" }), - ]); - - const envelope = await reportJson(projectDir, fakeHome); - - expect(envelope.totals.requests).toBe(1); - expect(envelope.read.identity_unusable).toBe("absent"); - expect(envelope.by_person.every((row) => row.resolution !== "mapped")).toBe(true); - - const textResult = await runCli( - ["telemetry", "report", ...REPORT_PERIOD, "--axis", "person"], - projectDir, - fakeHome - ); - expect(textResult.exitCode, textResult.stderr).toBe(0); - expect(textResult.stdout).toMatch(/no identity was declared/iu); - }); - - it("the two causes read as two different caveats end to end", async () => { - const unreadable = await setUp("person-cause-unreadable"); - cleanup = unreadable.cleanup; - const filePath = identityFileIn(unreadable.fakeHome); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, "not json at all", "utf8"); - const unreadableText = await runCli( - ["telemetry", "report", ...REPORT_PERIOD, "--axis", "person"], - unreadable.projectDir, - unreadable.fakeHome - ); - await unreadable.cleanup(); - - const absent = await setUp("person-cause-absent"); - cleanup = absent.cleanup; - const absentText = await runCli( - ["telemetry", "report", ...REPORT_PERIOD, "--axis", "person"], - absent.projectDir, - absent.fakeHome - ); - - expect(unreadableText.stdout).not.toBe(absentText.stdout); - expect(unreadableText.stdout).toMatch(/could not be read/iu); - expect(absentText.stdout).toMatch(/no identity was declared/iu); - }); - - it("an identity placed under a project-scoped config directory has no effect", async () => { - const { projectDir, fakeHome, tempDir, cleanup: c } = await setUp("person-project-scope"); - cleanup = c; - await useIdentity(projectDir, fakeHome, "person-a"); - const decoyDir = join(tempDir, "a-repository-or-a-ci-picked-this"); - await mkdir(decoyDir, { recursive: true }); - await writeFile( - join(decoyDir, "identity.json"), - `${JSON.stringify( - { person_id: "person-a", origin: "adopted", also_me: ["machine-b-id"] }, - null, - 2 - )}\n`, - "utf8" - ); - // AIDD_USER_CONFIG_DIR also relocates the sink, so records seeded under the real - // profile instead would make the report read empty and pass both assertions on nothing. - await seedSink(join(decoyDir, "telemetry"), [ - record({ tool: "claude", vendor_id: "s-1", turn_id: "t-1", person_id: "person-a" }), - record({ tool: "claude", vendor_id: "s-2", turn_id: "t-2", person_id: "machine-b-id" }), - ]); - - const envelope = await reportJson(projectDir, fakeHome, [], { - AIDD_USER_CONFIG_DIR: decoyDir, - }); - - expect(envelope.totals.requests).toBe(2); - // `machine-b-id` is listed under the decoy's `also_me`, never the real profile's, so it - // must stay unresolved rather than merge into person-a's row. - const mapped = envelope.by_person.find((row) => row.resolution === "mapped"); - expect(mapped?.identities).not.toContain("machine-b-id"); - const unresolved = envelope.by_person.find((row) => row.identities.includes("machine-b-id")); - expect(unresolved?.resolution).toBe("unresolved"); - }); -}); diff --git a/cli/tests/e2e/telemetry-identity.e2e.test.ts b/cli/tests/e2e/telemetry-identity.e2e.test.ts deleted file mode 100644 index 304084ddf..000000000 --- a/cli/tests/e2e/telemetry-identity.e2e.test.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { cp, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, identityFileIn, runCli, sinkDirIn } from "./helpers.js"; - -const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); - -const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; -const CODEX_SESSION = "019fae6f-2009-7cd3-86b2-b8f83481b160"; -const CLAUDE_RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; -const CODEX_RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FBW"; - -const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; - -async function seedJournal( - projectDir: string, - runId: string, - vendorId: string, - tool: string, - sessionStartAt: string, - turnEndAt: string -): Promise { - const runsDir = join(projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - const line = (value: unknown) => `${JSON.stringify(value)}\n`; - await writeFile( - join(runsDir, `${runId}__${vendorId}.jsonl`), - line({ - type: "session_start", - at: sessionStartAt, - run_id: runId, - tool, - vendor_id: vendorId, - project_id: "acme-widgets", - }) + line({ type: "turn_end", at: turnEndAt }) - ); -} - -// `telemetry read` refuses unless measurement is on, so every sweep here turns it on -// first, the same as a real project would. -async function writeSwitch(projectDir: string, enabled: boolean): Promise { - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled } }) - ); -} - -async function storedLines(fakeHome: string): Promise[]> { - const dir = sinkDirIn(fakeHome); - const entries = await readdir(dir).catch(() => []); - const lines: Record[] = []; - for (const entry of [...entries].sort().filter((e) => e.endsWith(".jsonl"))) { - const content = await readFile(join(dir, entry), "utf8"); - for (const raw of content.split("\n").filter((l) => l.trim() !== "")) { - lines.push(JSON.parse(raw)); - } - } - return lines; -} - -async function readIdentityFile(home: string): Promise { - try { - return await readFile(identityFileIn(home), "utf8"); - } catch { - return null; - } -} - -async function seedIdentity(home: string, personId: string): Promise { - const filePath = identityFileIn(home); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, `${JSON.stringify({ person_id: personId }, null, 2)}\n`); -} - -describe("aidd telemetry identity — the journey and its edge cases", () => { - it("walks identity -> use -> use --name -> identity -> off, each state legible from stdout", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-journey"); - try { - const off = await runCli(["telemetry", "identity"], projectDir, fakeHome); - expect(off.exitCode, off.stderr).toBe(0); - expect(off.stdout).toContain("off"); - expect(off.stdout).not.toContain("on,"); - - const on = await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - expect(on.exitCode, on.stderr).toBe(0); - expect(on.stdout).toMatch(/on, [0-9a-f-]{36}/u); - const mintedId = (await readIdentityFile(fakeHome)) ?? ""; - const personId = (JSON.parse(mintedId) as { person_id: string }).person_id; - expect(personId).toMatch(UUID_V4); - - const name = await runCli( - ["telemetry", "identity", "use", "--name", "Baptiste"], - projectDir, - fakeHome - ); - expect(name.exitCode, name.stderr).toBe(0); - - const status = await runCli(["telemetry", "identity"], projectDir, fakeHome); - expect(status.exitCode, status.stderr).toBe(0); - expect(status.stdout).toContain(personId); - expect(status.stdout).toContain('"Baptiste"'); - - const finalOff = await runCli(["telemetry", "identity", "off"], projectDir, fakeHome); - expect(finalOff.exitCode, finalOff.stderr).toBe(0); - expect(finalOff.stdout).toMatch(/new records/iu); - expect(await readIdentityFile(fakeHome)).toBeNull(); - } finally { - await cleanup(); - } - }); - - it("a minted identity discloses what it attaches to, and what it never attaches to", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-disclosure-on"); - try { - const result = await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("Attaches to: records this machine reads locally"); - expect(result.stdout).toContain("Never attaches to:"); - expect(result.stdout).toContain("the run journal"); - expect(result.stdout).toContain("a session already recorded"); - expect(result.stdout).toContain("a tool's own export"); - } finally { - await cleanup(); - } - }); - - it("off says past records keep the identifier they were written with", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-disclosure-off"); - try { - await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - - const result = await runCli(["telemetry", "identity", "off"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - // Withdrawing must never read as erasing what was already measured under the name. - expect(result.stdout).toContain("Records already stored keep the identifier"); - expect(result.stdout).toContain("none are changed"); - } finally { - await cleanup(); - } - }); - - it("a second use reports the same identifier, never a new one", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-double-on"); - try { - await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - const first = (await readIdentityFile(fakeHome)) ?? ""; - - const second = await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - - expect(second.exitCode, second.stderr).toBe(0); - expect(second.stdout).toContain("already in effect"); - expect(await readIdentityFile(fakeHome)).toBe(first); - } finally { - await cleanup(); - } - }); - - it("off removes the whole declaration, stating how many added identifiers went with it", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-off-removes-alsome"); - try { - await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - await runCli(["telemetry", "identity", "link", "a-second-machine"], projectDir, fakeHome); - - const result = await runCli(["telemetry", "identity", "off"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/1 added identifier removed with it/iu); - - const status = await runCli(["telemetry", "identity"], projectDir, fakeHome); - expect(status.stdout).not.toContain("a-second-machine"); - } finally { - await cleanup(); - } - }); - - it("mints for a name given before anything stands, rather than refusing", async () => { - // `use` is the verb that opts in, so a name given with nothing standing is a person - // saying who they are, not renaming a thing that is not there. - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-name-first"); - try { - const result = await runCli( - ["telemetry", "identity", "use", "--name", "Baptiste"], - projectDir, - fakeHome - ); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("Display name: Baptiste"); - const written = await readIdentityFile(fakeHome); - expect(written).not.toBeNull(); - expect(JSON.parse(written ?? "{}").display_name).toBe("Baptiste"); - expect(JSON.parse(written ?? "{}").origin).toBe("minted"); - } finally { - await cleanup(); - } - }); - - it("an unreadable identity file surfaces as an error, never as 'no identity is set'", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-unreadable"); - try { - await mkdir(identityFileIn(fakeHome), { recursive: true }); - - const result = await runCli(["telemetry", "identity"], projectDir, fakeHome); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/could not read/iu); - expect(result.stderr).not.toMatch(/records carry no person/u); - } finally { - await cleanup(); - } - }); - - // Pins the adapter half of `forget()`, out of the use-case tests' reach: with `force: true` - // on the `rm`, a profile that never had a file still prints the withdrawal message. - it("off on a profile that never had an identity says there was nothing to withdraw", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-off-absent"); - try { - const result = await runCli(["telemetry", "identity", "off"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/nothing to withdraw/iu); - expect(result.stdout).not.toMatch(/removed with it/iu); - } finally { - await cleanup(); - } - }); - - // A file naming nobody parses to "nobody chose" while still sitting on disk. - it("off removes an identity file that exists but names nobody", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-off-nameless"); - try { - const file = identityFileIn(fakeHome); - await mkdir(dirname(file), { recursive: true }); - await writeFile(file, `${JSON.stringify({ person_id: "", origin: "adopted" })}\n`, "utf8"); - - const result = await runCli(["telemetry", "identity", "off"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).not.toMatch(/nothing to withdraw/iu); - expect(await readIdentityFile(fakeHome)).toBeNull(); - } finally { - await cleanup(); - } - }); - - // `off` is how a person gets out: no state a damaged file can put someone in may be one - // withdrawing cannot get them out of. - it("off still withdraws a damaged identity file, and says it was discarded", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-off-damaged"); - try { - await mkdir(identityFileIn(fakeHome), { recursive: true }); - - const result = await runCli(["telemetry", "identity", "off"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/could not be read|could not read/iu); - expect(result.stdout).toMatch(/discard/iu); - expect(await readIdentityFile(fakeHome)).toBeNull(); - } finally { - await cleanup(); - } - }); - - it("a repository pointing AIDD_USER_CONFIG_DIR elsewhere never moves an existing identity", async () => { - const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("identity-env-override"); - try { - await seedIdentity(fakeHome, "the-os-profile-identity"); - const elsewhere = join(tempDir, "repo-pointed-elsewhere"); - await mkdir(elsewhere, { recursive: true }); - await writeFile( - join(elsewhere, "identity.json"), - `${JSON.stringify({ person_id: "should-never-be-read" }, null, 2)}\n` - ); - - const result = await runCli(["telemetry", "identity"], projectDir, fakeHome, { - env: { AIDD_USER_CONFIG_DIR: elsewhere }, - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("the-os-profile-identity"); - expect(result.stdout).not.toContain("should-never-be-read"); - } finally { - await cleanup(); - } - }); - - // The test above seeds both profiles, so an implementation that merely prefers the OS one - // would pass it too: an empty OS profile beside a populated AIDD_USER_CONFIG_DIR reads off. - it("an empty OS profile beside a populated AIDD_USER_CONFIG_DIR still reads off", async () => { - const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv( - "identity-env-override-empty" - ); - try { - const elsewhere = join(tempDir, "repo-pointed-elsewhere"); - await mkdir(elsewhere, { recursive: true }); - await writeFile( - join(elsewhere, "identity.json"), - `${JSON.stringify({ person_id: "forced-by-the-repo" }, null, 2)}\n` - ); - - const result = await runCli(["telemetry", "identity"], projectDir, fakeHome, { - env: { AIDD_USER_CONFIG_DIR: elsewhere }, - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toMatch(/off/u); - expect(result.stdout).not.toContain("forced-by-the-repo"); - expect(await readIdentityFile(fakeHome)).toBeNull(); - } finally { - await cleanup(); - } - }); -}); - -describe("what a default install actually stores: reading every line it wrote", () => { - it("carries no person field anywhere, proven from the stored bytes", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-default-install"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await cp(LOCAL_COST_FIXTURES, fakeHome, { recursive: true }); - await seedJournal( - projectDir, - CLAUDE_RUN_ID, - CLAUDE_SESSION, - "claude", - "2026-08-05T19:00:00Z", - "2026-08-05T20:00:00Z" - ); - - const result = await runCli(["telemetry", "read"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); - - const lines = await storedLines(fakeHome); - expect(lines.length).toBeGreaterThan(0); - for (const line of lines) { - expect(line).not.toHaveProperty("person_id"); - expect(line).not.toHaveProperty("person_display_name"); - } - } finally { - await cleanup(); - } - }); -}); - -describe("a choice made today does not reach backwards", () => { - it("records stored before opting in stay unnamed; only later records carry it", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-no-retro"); - try { - await gitInit(projectDir); - await writeSwitch(projectDir, true); - await cp(LOCAL_COST_FIXTURES, fakeHome, { recursive: true }); - await seedJournal( - projectDir, - CLAUDE_RUN_ID, - CLAUDE_SESSION, - "claude", - "2026-08-05T19:00:00Z", - "2026-08-05T20:00:00Z" - ); - - const firstRead = await runCli(["telemetry", "read"], projectDir, fakeHome); - expect(firstRead.exitCode, firstRead.stderr).toBe(0); - const beforeOptIn = await storedLines(fakeHome); - expect(beforeOptIn.length).toBeGreaterThan(0); - expect(beforeOptIn.every((line) => !("person_id" in line))).toBe(true); - - const on = await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - expect(on.exitCode, on.stderr).toBe(0); - const personId = ( - JSON.parse((await readIdentityFile(fakeHome)) ?? "{}") as { - person_id: string; - } - ).person_id; - - await seedJournal( - projectDir, - CODEX_RUN_ID, - CODEX_SESSION, - "codex", - "2026-07-29T15:10:00Z", - "2026-07-29T15:30:00Z" - ); - const secondRead = await runCli(["telemetry", "read"], projectDir, fakeHome); - expect(secondRead.exitCode, secondRead.stderr).toBe(0); - const afterOptIn = await storedLines(fakeHome); - - const claudeLines = afterOptIn.filter((line) => line.vendor_id === CLAUDE_SESSION); - const codexLines = afterOptIn.filter((line) => line.vendor_id === CODEX_SESSION); - expect(claudeLines.length).toBeGreaterThan(0); - expect(codexLines.length).toBeGreaterThan(0); - expect(claudeLines.every((line) => !("person_id" in line))).toBe(true); - expect(codexLines.every((line) => line.person_id === personId)).toBe(true); - } finally { - await cleanup(); - } - }); -}); - -// The one claim nothing else in this file owns: the exact on-disk byte format, and the -// file and directory modes. -describe("the on-disk format the deleted script produced", () => { - it("name: matches the exact bytes the script wrote from the same starting identity", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-format-name"); - try { - await seedIdentity(fakeHome, "shared-person-id"); - - const result = await runCli( - ["telemetry", "identity", "use", "--name", "Baptiste"], - projectDir, - fakeHome - ); - - expect(result.exitCode, result.stderr).toBe(0); - const file = await readFile(identityFileIn(fakeHome), "utf8"); - // `seedIdentity` writes the no-`origin` shape; reading it back defaults `origin` to - // `"minted"`, and every write from here on carries it. - expect(file).toBe( - '{\n "person_id": "shared-person-id",\n "origin": "minted",\n "display_name": "Baptiste"\n}\n' - ); - } finally { - await cleanup(); - } - }); - - it("use with no identifier: from empty, mints a v4 identifier recording how it was obtained", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-format-on-empty"); - try { - const result = await runCli(["telemetry", "identity", "use"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - const file = await readFile(identityFileIn(fakeHome), "utf8"); - const personId = (JSON.parse(file) as { person_id: string }).person_id; - expect(personId).toMatch(UUID_V4); - // `mint()` records `origin: "minted"`: how an identity was obtained is knowable only at - // the moment it is created. - expect(file.replace(personId, "")).toBe( - '{\n "person_id": "",\n "origin": "minted"\n}\n' - ); - - if (process.platform !== "win32") { - expect((await stat(identityFileIn(fakeHome))).mode & 0o777).toBe(0o600); - expect((await stat(dirname(identityFileIn(fakeHome)))).mode & 0o777).toBe(0o700); - } - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts b/cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts deleted file mode 100644 index 653557ceb..000000000 --- a/cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { createTestEnv, runCli } from "./helpers.js"; - -/** `00-init`'s commands are stateful, so they are not run in the order the markdown walk - * finds them: the most destructive runs only once nothing after it depends on what it removes. */ -const REPO_ROOT = REPOSITORY_ROOT; -const SKILL_DIR = join(REPO_ROOT, "plugins", "aidd-telemetry", "skills", "00-init"); -const COST_LOCATE = join( - REPO_ROOT, - "plugins", - "aidd-telemetry", - "skills", - "01-cost", - "actions", - "01-locate.md" -); -const INIT_CHECK = join(SKILL_DIR, "actions", "01-check.md"); - -/** Every `aidd telemetry …` command 00-init's own markdown tells an agent to run. */ -function commandsNamedBySkill(): string[] { - const found = new Set(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith(".md")) { - for (const match of readFileSync(full, "utf8").matchAll(/`(aidd telemetry [^`]+)`/gu)) { - found.add(match[1] ?? ""); - } - } - } - }; - walk(SKILL_DIR); - return [...found]; -} - -/** `link` refuses with `IdentityRequiredToLinkError` when no identity stands, so it ranks - * after `use`; a stable sort keeps ties in the file walk's own order. */ -function orderForExecution(commands: readonly string[]): string[] { - const rank = (command: string): number => { - if (/\bforget\b/u.test(command)) return 4; - if (/\btelemetry on\b/u.test(command)) return 0; - if (/\boff\b/u.test(command)) return 3; - if (/\b(?:un)?link\b/u.test(command)) return 2; - return 1; - }; - return [...commands].sort((a, b) => rank(a) - rank(b)); -} - -/** `""` stands for a display name the person supplies; expanded so the command can - * actually be run, rather than skipped. */ -function runnable(command: string): string[] { - return command - .replace(/"?"?/gu, "Baptiste") - .split(/\s+/u) - .slice(1); -} - -describe("E2E: 00-init calls the CLI", () => { - it("every command the skill names is one the CLI accepts, run in a safe order", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("init-skill-commands"); - try { - const commands = orderForExecution(commandsNamedBySkill()); - - // A closure test over an empty extraction passes vacuously. - expect(commands.length).toBeGreaterThanOrEqual(4); - - for (const command of commands) { - const result = await runCli(runnable(command), projectDir, fakeHome); - expect(result.exitCode, `${command}\n${result.stderr}`).toBe(0); - } - } finally { - await cleanup(); - } - }); - - it("the skill's account names both the preview and the confirmed removal", () => { - // Pinned on the exact strings: an account that dropped `--yes` would still contain - // "aidd telemetry forget" and pass a substring check, showing nobody how to remove anything. - const commands = commandsNamedBySkill(); - expect(commands).toContain("aidd telemetry forget"); - expect(commands).toContain("aidd telemetry forget --yes"); - }); - - it("the sweep itself would fail if the skill's account named a command the CLI refuses", async () => { - // The guard's own proof: run the mechanism against text naming something wrong and - // require it to be caught, rather than trusting that it would be. - const { projectDir, fakeHome, cleanup } = await createTestEnv("init-skill-commands-drift"); - try { - const driftedCommand = "aidd telemetry forget --confirm"; // not a flag `forget` accepts - const result = await runCli(runnable(driftedCommand), projectDir, fakeHome); - expect(result.exitCode).not.toBe(0); - } finally { - await cleanup(); - } - }); - - it("names no script beside itself any more", () => { - const named = new Set(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith(".md") && /\.cjs\b/u.test(readFileSync(full, "utf8"))) { - named.add(entry.name); - } - } - }; - walk(SKILL_DIR); - - expect([...named]).toEqual([]); - }); - - it("01-check's absent-CLI wording has not drifted from 01-cost's own copy", () => { - const sharedRule = (text: string): string => { - const start = text.indexOf("No output, or a command that is not found"); - const marker = "cost nothing."; - const end = text.indexOf(marker) + marker.length; - expect(start).toBeGreaterThan(-1); - expect(end).toBeGreaterThan(start); - return text.slice(start, end); - }; - - const costWording = sharedRule(readFileSync(COST_LOCATE, "utf8")); - const initWording = sharedRule(readFileSync(INIT_CHECK, "utf8")); - - expect(initWording).toBe(costWording); - }); -}); diff --git a/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts b/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts deleted file mode 100644 index 8bdcc7428..000000000 --- a/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { execFile, execFileSync } from "node:child_process"; -import { readdir, readFile, writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { promisify } from "node:util"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { createTestEnv, gitInit, runCliFast } from "./helpers.js"; - -const execFileAsync = promisify(execFile); -const REPO_ROOT = REPOSITORY_ROOT; -const JOURNAL_HOOK = resolve(REPO_ROOT, "plugins/aidd-telemetry/hooks/journal.cjs"); -const SETUP_ARGS = [ - "setup", - "--source", - "local", - "--path", - REPO_ROOT, - "--ai", - "claude", - "--plugins", - "aidd-telemetry", - "--yes", -] as const; - -describe("aidd setup never offers the run journal to a commit", () => { - let projectDir: string; - let fakeHome: string; - let cleanup: () => Promise; - - beforeEach(async () => { - ({ projectDir, fakeHome, cleanup } = await createTestEnv("journal-gitignore")); - await gitInit(projectDir); - }); - - afterEach(async () => { - await cleanup(); - }); - - function env(): NodeJS.ProcessEnv { - return { ...environmentWithoutGitVariables(process.env), HOME: fakeHome }; - } - - function journal(event: string, payload: Record): void { - execFileSync(process.execPath, [JOURNAL_HOOK, event], { - input: JSON.stringify(payload), - cwd: projectDir, - env: env(), - }); - } - - it("adds the run journal to .gitignore, and covers nothing wider", async () => { - const { exitCode, stderr } = await runCliFast([...SETUP_ARGS], projectDir, fakeHome); - - expect(exitCode, stderr).toBe(0); - const gitignore = await readFile(`${projectDir}/.gitignore`, "utf8"); - expect(gitignore).toContain("aidd_docs/runs/"); - expect(gitignore).not.toContain("aidd_docs/*"); - expect(gitignore).not.toMatch(/^aidd_docs\/$/mu); - }); - - it("stops offering a journalled session to git status once measurement is on", async () => { - await runCliFast([...SETUP_ARGS], projectDir, fakeHome); - // `aidd setup` installs the plugin; turning measurement on is a separate, later act - - // done here the same way `.aidd/config.json`'s only reader (the journal hook) reads it. - await writeFile( - `${projectDir}/.aidd/config.json`, - JSON.stringify({ telemetry: { enabled: true } }) - ); - journal("session-start", { - session_id: "33333333-3333-4333-8333-333333333333", - hook_event_name: "SessionStart", - cwd: projectDir, - transcript_path: `${fakeHome}/.claude/projects/fake/x.jsonl`, - }); - - const written = await readdir(`${projectDir}/aidd_docs/runs`); - expect( - written.some((f) => f.endsWith(".jsonl")), - "the hook did not actually journal" - ).toBe(true); - - const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1"], { - cwd: projectDir, - env: environmentWithoutGitVariables(process.env), - }); - expect(stdout).not.toContain("aidd_docs"); - }); -}); diff --git a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts deleted file mode 100644 index 310c1385c..000000000 --- a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { execFile, execFileSync } from "node:child_process"; -import { existsSync, realpathSync } from "node:fs"; -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { promisify } from "node:util"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { cliPath, copyFixtureTree, pathWithoutAidd } from "./helpers.js"; - -const execFileAsync = promisify(execFile); -const REPO_ROOT = REPOSITORY_ROOT; -const PLUGIN = join(REPO_ROOT, "plugins", "aidd-telemetry"); -const JOURNAL_HOOK = join(PLUGIN, "hooks", "journal.cjs"); -const HOOK_FIXTURES = join(REPO_ROOT, "scripts", "__tests__", "fixtures"); -const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); - -const SESSION = "22222222-2222-4222-8222-222222222222"; -const TASK = "2026_08/2026_08_21_probe-task"; -const PERIOD = ["--from", "2026-08-01", "--to", "2026-08-31"] as const; -/** Every token the captured Claude Code session billed, recomputed in - * `telemetry-plugin-standalone.e2e.test.ts` from the transcript itself. */ -const SESSION_TOKENS = "151,826"; - -interface Run { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -} - -/** - * Each step is only meaningful because of the one before it, so it runs as one sequence: - * a test per step would pass while the sequence was broken. - */ -describe("measurement, from nothing to off and back", () => { - let projectDir: string; - let fakeHome: string; - let configDir: string; - let tempDir: string; - - beforeEach(async () => { - tempDir = realpathSync(await mkdtemp(join(tmpdir(), "aidd-lifecycle-"))); - projectDir = join(tempDir, "project"); - fakeHome = join(tempDir, "home"); - configDir = join(tempDir, "config"); - await mkdir(projectDir, { recursive: true }); - await mkdir(fakeHome, { recursive: true }); - execFileSync("git", ["init", "-q", projectDir], { - env: environmentWithoutGitVariables(process.env), - }); - // The tool's own transcript, exactly as a machine that ran the session would hold it. - await copyFixtureTree(LOCAL_COST_FIXTURES, fakeHome); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - /** Only node's own directory: no `aidd`, and nothing from this repository's - * `node_modules`. The plugin has to be enough. */ - function env(): NodeJS.ProcessEnv { - return { - ...environmentWithoutGitVariables(process.env), - PATH: pathWithoutAidd(), - HOME: fakeHome, - AIDD_USER_CONFIG_DIR: configDir, - }; - } - - async function run(bin: string, args: readonly string[]): Promise { - try { - const { stdout, stderr } = await execFileAsync(process.execPath, [bin, ...args], { - cwd: projectDir, - env: env(), - }); - return { stdout, stderr, exitCode: 0 }; - } catch (error) { - const failed = error as { stdout?: string; stderr?: string; code?: number }; - return { - stdout: failed.stdout ?? "", - stderr: failed.stderr ?? "", - exitCode: failed.code ?? 1, - }; - } - } - - /** Invoked by its own built path, like `measure`, so the `PATH` this file strips `aidd` - * from proves only that the hooks below need neither the switch nor the reader. */ - const switchTo = (state: "on" | "off") => - run(cliPath(), state === "on" ? ["telemetry", "on", "--yes"] : ["telemetry", "off"]); - const measure = (args: readonly string[]) => run(cliPath(), args.slice()); - - /** One captured hook payload, retargeted at this project and session. The hook decides - * the host from the payload's own shape, so nothing here tells it which tool it is. */ - async function hook(fixture: string, event: string, extra: object = {}): Promise { - const payload = JSON.parse(await readFile(join(HOOK_FIXTURES, `${fixture}.json`), "utf8")); - execFileSync(process.execPath, [JOURNAL_HOOK, event], { - input: JSON.stringify({ - ...payload, - session_id: SESSION, - transcript_path: join(fakeHome, ".claude", "projects", "fake-project", `${SESSION}.jsonl`), - cwd: projectDir, - ...extra, - }), - cwd: projectDir, - env: env(), - }); - } - - async function aSessionRuns(): Promise { - await hook("claude-code-session-start", "session-start"); - await hook("claude-code-post-tool-use-skill", "tool-used", { - tool_input: { skill: "aidd-dev:02-implement" }, - }); - const notes = join( - projectDir, - "aidd_docs", - "tasks", - "2026_08", - "2026_08_21_probe-task", - "notes.md" - ); - await mkdir(dirname(notes), { recursive: true }); - await writeFile(notes, "probe\n", "utf-8"); - await hook("claude-code-post-tool-use-write", "tool-used", { - tool_input: { file_path: notes, content: "probe" }, - }); - await hook("claude-code-session-start", "turn-end"); - } - - async function runFiles(): Promise { - const dir = join(projectDir, "aidd_docs", "runs"); - return existsSync(dir) ? await readdir(dir) : []; - } - - /** Counting files would miss a session that carries on: a run file is named for its - * session, so a second turn appends to the file the first turn opened. */ - async function journalLines(): Promise { - const dir = join(projectDir, "aidd_docs", "runs"); - let total = 0; - for (const name of await runFiles()) { - total += (await readFile(join(dir, name), "utf8")).trim().split("\n").filter(Boolean).length; - } - return total; - } - - it("lives the whole sequence, each step meaning what the one before it set up", async () => { - const beforeAnything = await measure(["telemetry", "report", ...PERIOD]); - expect(beforeAnything.exitCode, beforeAnything.stderr).toBe(0); - expect(beforeAnything.stdout).toContain("nothing in this period"); - // No config at all reads as off, the same as an explicit "off": never a bare "0" for - // what nothing measured. - expect(beforeAnything.stdout).toMatch(/this project's own switch is off/u); - expect(beforeAnything.stdout).not.toMatch(/\bsessions\s+0\b/u); - expect(existsSync(join(projectDir, ".aidd", "config.json"))).toBe(false); - - await aSessionRuns(); - expect(await runFiles()).toEqual([]); - - expect((await switchTo("on")).exitCode).toBe(0); - - await aSessionRuns(); - expect(await runFiles()).toHaveLength(1); - - const read = await measure(["telemetry", "read"]); - expect(read.stdout).toContain("Claude Code: read (4 new of 4)"); - const reported = await measure(["telemetry", "report", ...PERIOD]); - expect(reported.stdout).toContain(SESSION_TOKENS); - expect(reported.stdout).toContain("probe-echo"); - // While on, figures that visibly work need no sentence confirming the switch. - expect(reported.stdout).not.toContain("measurement is off"); - const byTask = await measure(["telemetry", "report", ...PERIOD, "--task", TASK]); - expect(byTask.stdout).toContain(`task ${TASK}`); - expect(byTask.stdout).toContain(SESSION_TOKENS); - - expect((await switchTo("off")).exitCode).toBe(0); - const afterOff = await measure(["telemetry", "report", ...PERIOD]); - expect(afterOff.stdout).toContain(SESSION_TOKENS); - // Off and holding real history: neither fact stands in for the other. - expect(afterOff.stdout).toMatch(/this project's own switch is off/u); - - // The switch is read at the moment of every write, not once at startup. - const before = await journalLines(); - expect(before).toBeGreaterThan(0); - await aSessionRuns(); - expect(await journalLines()).toBe(before); - - // Recording resumes into the journal that already exists, so nothing measured before - // is orphaned. - await switchTo("on"); - await aSessionRuns(); - expect(await journalLines()).toBeGreaterThan(before); - expect(await runFiles()).toHaveLength(1); - - const second = await measure(["telemetry", "read"]); - expect(second.stdout).toContain("Claude Code: read (0 new of 4)"); - expect((await measure(["telemetry", "report", ...PERIOD])).stdout).toContain(SESSION_TOKENS); - }, 60_000); - - it("leaves the project's own config alone through the whole cycle", async () => { - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ somethingElse: { kept: true } }), - "utf-8" - ); - - await switchTo("on"); - await switchTo("off"); - await switchTo("on"); - - const config = JSON.parse(await readFile(join(projectDir, ".aidd", "config.json"), "utf8")); - expect(config.somethingElse).toEqual({ kept: true }); - expect(config.telemetry).toEqual({ enabled: true }); - }, 30_000); - - it("answers a program the same way through the same cycle", async () => { - await switchTo("on"); - await aSessionRuns(); - await measure(["telemetry", "read"]); - - const envelope = JSON.parse( - (await measure(["telemetry", "report", ...PERIOD, "--json"])).stdout - ); - await switchTo("off"); - const afterOff = JSON.parse( - (await measure(["telemetry", "report", ...PERIOD, "--json"])).stdout - ); - - // Turning measurement off changes what is recorded next, never what a past period - // answers. `measurement_enabled` is the one exception: it names the switch's state now. - expect(envelope.measurement_enabled).toBe(true); - expect(afterOff.measurement_enabled).toBe(false); - const { measurement_enabled: _before, ...historicalFigures } = envelope; - const { measurement_enabled: _after, ...historicalFiguresAfterOff } = afterOff; - expect(historicalFiguresAfterOff).toEqual(historicalFigures); - expect(envelope.cost_report_version).toBe(15); - }, 60_000); -}); diff --git a/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts b/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts deleted file mode 100644 index 59ff480b4..000000000 --- a/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { realpathSync } from "node:fs"; -import { chmod, cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; -import { delimiter, join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { createTestEnv, gitInit, runCli, sinkDirIn } from "./helpers.js"; - -/** Three readable tools through one report, from real captures: hook payloads replayed - * through the journal hook itself, and a stand-in `opencode` on the path, since the reader - * shells out and an e2e must not depend on the machine having that tool installed. */ -const REPO_ROOT = REPOSITORY_ROOT; -const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); -const HOOK_FIXTURES = join(REPO_ROOT, "scripts", "__tests__", "fixtures"); -const JOURNAL_HOOK = join(REPO_ROOT, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); -const OPENCODE_EXPORT_FIXTURE = join( - process.cwd(), - "tests", - "fixtures", - "telemetry-sink", - "opencode-export.json" -); - -const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; -const CODEX_SESSION = "019fae6f-2009-7cd3-86b2-b8f83481b160"; -const OPENCODE_SESSION = "ses_probe000000000000000000000"; - -// The day each tool's captured work happened. They are months apart, which is the point: -// one period has to reach all three, and each record has to land on its own day. -const OLDEST_WORK_DAY = "2026-03-16"; -const TASK = "2026_08/2026_08_21_probe-task"; -const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - -// A run id is a ULID, and the journal splits a run file's name on that fixed length. -const CODEX_RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FBW"; - -async function loadHookFixture(name: string): Promise> { - return JSON.parse(await readFile(join(HOOK_FIXTURES, `${name}.json`), "utf8")); -} - -describe("aidd telemetry, across every tool that can be read", () => { - let projectDir: string; - let fakeHome: string; - let binDir: string; - let cleanup: (() => Promise) | undefined; - - beforeEach(async () => { - const env = await createTestEnv("telemetry-multi-tool"); - cleanup = env.cleanup; - // git resolves symlinks in --show-toplevel and macOS puts tmpdir behind one; the hook - // compares the two, and would reject every written path if they disagreed. - projectDir = realpathSync(env.projectDir); - fakeHome = realpathSync(env.fakeHome); - binDir = join(fakeHome, "bin"); - await gitInit(projectDir); - await writeConfig(); - await cp(LOCAL_COST_FIXTURES, fakeHome, { recursive: true }); - await installOpencodeStandIn(); - }); - - afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - }); - - async function writeConfig(): Promise { - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - "utf-8" - ); - } - - /** Answers `opencode export --sanitize` with the captured payload for its own session - * and no other. Answering for any id at all is what colliding identifiers would look like, - * and would store one session's figures under every vendor id; a real binary exits 1. */ - async function installOpencodeStandIn(): Promise { - await mkdir(binDir, { recursive: true }); - const standIn = join(binDir, "opencode"); - await writeFile( - standIn, - `#!/bin/sh\nif [ "$1" = "export" ] && [ "$2" = "${OPENCODE_SESSION}" ]; then cat ${OPENCODE_EXPORT_FIXTURE}; exit 0; fi\necho "session not found" >&2\nexit 1\n`, - "utf-8" - ); - await chmod(standIn, 0o755); - } - - function cli(args: readonly string[]) { - // The stand-in first, node's own directory after it, so `node` still resolves. - return runCli([...args], projectDir, fakeHome, { - env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` }, - }); - } - - /** Replays one captured payload through the journal hook, retargeted at this test's - * repository and session. The hook decides the host from the payload's own shape. */ - function replayHook(payload: Record, event: string): void { - execFileSync("node", [JOURNAL_HOOK, event], { - input: JSON.stringify(payload), - cwd: projectDir, - encoding: "utf8", - }); - } - - async function journalClaudeSession(): Promise { - const transcript = `${fakeHome}/.claude/projects/fake-project/${CLAUDE_SESSION}.jsonl`; - const start = await loadHookFixture("claude-code-session-start"); - replayHook( - { ...start, session_id: CLAUDE_SESSION, transcript_path: transcript, cwd: projectDir }, - "session-start" - ); - - const skill = await loadHookFixture("claude-code-post-tool-use-skill"); - replayHook( - { ...skill, session_id: CLAUDE_SESSION, transcript_path: transcript, cwd: projectDir }, - "tool-used" - ); - - const write = await loadHookFixture("claude-code-post-tool-use-write"); - const notes = join( - projectDir, - "aidd_docs", - "tasks", - "2026_08", - "2026_08_21_probe-task", - "notes.md" - ); - await mkdir(join(projectDir, "aidd_docs", "tasks", "2026_08", "2026_08_21_probe-task"), { - recursive: true, - }); - await writeFile(notes, "probe\n", "utf-8"); - replayHook( - { - ...write, - session_id: CLAUDE_SESSION, - transcript_path: transcript, - cwd: projectDir, - tool_input: { file_path: notes, content: "probe" }, - }, - "tool-used" - ); - } - - /** Hand-written, unlike the Claude Code one: the hook stamps every line with the moment it - * runs, so an interval reaching the captured rollout can only be constructed. */ - async function journalCodexSessionBackdated(): Promise { - const runsDir = join(projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - const lines = [ - { - type: "session_start", - at: "2026-07-29T15:10:00Z", - schema_version: 2, - run_id: CODEX_RUN_ID, - project_id: "acme/probe", - project_remote: null, - tool: "codex", - vendor_id: CODEX_SESSION, - vendor_field: "conversation.id", - }, - { type: "step_start", at: "2026-07-29T15:11:00Z", skill: "aidd-dev:02-implement" }, - { type: "turn_end", at: "2026-07-29T15:30:00Z" }, - ]; - await writeFile( - join(runsDir, `${CODEX_RUN_ID}__${CODEX_SESSION}.jsonl`), - `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8" - ); - } - - async function readEveryTool(): Promise { - for (const session of [CLAUDE_SESSION, CODEX_SESSION, OPENCODE_SESSION]) { - const result = await cli(["telemetry", "read", "--session", session]); - expect(result.exitCode, `reading ${session}: ${result.stderr}`).toBe(0); - } - } - - function daysBackToTheOldestWork(): string { - const elapsed = Date.now() - Date.parse(`${OLDEST_WORK_DAY}T00:00:00Z`); - return String(Math.ceil(elapsed / MILLISECONDS_PER_DAY)); - } - - async function reportEverything(): Promise { - const result = await cli(["telemetry", "report", "--days", daysBackToTheOldestWork()]); - expect(result.exitCode, result.stderr).toBe(0); - return result.stdout; - } - - it("reads three tools' own files and reports all three in one period", async () => { - await journalClaudeSession(); - await journalCodexSessionBackdated(); - await readEveryTool(); - - const out = await reportEverything(); - - // No tool's own files carry a dollar figure on any reader wired today, and Claude Code's cost - // reaches the sink only through OTLP, so a zero here would read as free. - expect(out).toMatch(/Claude Code\s+amount unknown/u); - expect(out).toMatch(/Codex\s+amount unknown/u); - expect(out).toMatch(/OpenCode\s+amount unknown/u); - expect(out).not.toContain("$"); - // Codex's two turns, recomputed by hand from the rollout's own increments. - expect(out).toContain("183,939"); - // The three tools' figures stay their own rather than being pooled. One session's - // figures, counted once — not once per session that happened to be read. - expect(out).toMatch(/OpenCode\s+amount unknown\s+145,285 tokens/u); - expect(out).toMatch(/Claude Code\s+amount unknown\s+151,826 tokens/u); - }); - - it("names the one tool nothing here can read, with its measured reason", async () => { - const out = await reportEverything(); - - expect(out).toMatch(/Cursor\s+not covered — It writes no token count/u); - // Copilot is covered, but no session of its own was journalled here, so "nothing in this - // period" is the correct answer, never "not covered". - expect(out).toMatch(/GitHub Copilot\s+nothing in this period/u); - expect(out).not.toMatch(/GitHub Copilot\s+not covered/u); - }); - - it("shows all three attribution strengths at once, each from its own source", async () => { - await journalClaudeSession(); - await journalCodexSessionBackdated(); - await readEveryTool(); - - const out = await reportEverything(); - const mix = out.slice(out.indexOf("attribution ")); - - // Claude Code's subagent transcript states its own skill, on the line with the counters. - expect(out).toContain("probe-echo"); - expect(mix).toContain("stated by the tool"); - // Codex states none, so its records fall inside the journal's step interval instead. - expect(out).toContain("aidd-dev:02-implement"); - expect(mix).toContain("from a journal interval"); - // OpenCode has no journal beside it and states nothing. - expect(mix).toContain("unattributed"); - }); - - it("stamps every stored record with this CLI's own version, read through the real binary - never the framework's", async () => { - await journalClaudeSession(); - await readEveryTool(); - - const sinkDir = sinkDirIn(fakeHome); - const dayFiles = await readdir(sinkDir); - const records = ( - await Promise.all( - dayFiles - .filter((name) => name.endsWith(".jsonl")) - .map(async (name) => readFile(join(sinkDir, name), "utf8")) - ) - ) - .join("\n") - .split("\n") - .filter((line) => line.trim() !== "") - .map((line) => JSON.parse(line) as Record); - - expect(records.length).toBeGreaterThan(0); - // This CLI's own package.json - never the framework's root one, which sits one - // directory further up and carries a different version entirely. - const cliPackageJson = JSON.parse( - await readFile(join(process.cwd(), "package.json"), "utf8") - ) as { version: string }; - for (const record of records) { - expect(record.cli_version).toBe(cliPackageJson.version); - } - }); - - it("attributes a task from what the journal hook itself recorded", async () => { - await journalClaudeSession(); - await journalCodexSessionBackdated(); - await readEveryTool(); - - const runFiles = await readdir(join(projectDir, "aidd_docs", "runs")); - const claudeRun = runFiles.find((name) => name.endsWith(`__${CLAUDE_SESSION}.jsonl`)); - const journal = await readFile(join(projectDir, "aidd_docs", "runs", claudeRun ?? ""), "utf8"); - expect(journal).toContain('"file_written"'); - - const result = await cli([ - "telemetry", - "report", - "--days", - daysBackToTheOldestWork(), - "--task", - TASK, - ]); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain(`task ${TASK}`); - // Only Claude Code wrote into that folder. "In this selection", not "in this period": --task - // narrows the record set first, so Codex's zero is the filter's doing, not real idleness. - expect(result.stdout).not.toContain("183,939"); - expect(result.stdout).toMatch(/Codex\s+nothing in this selection/u); - }); - - // The Claude Code transcript carries a captured `` line, a notice the tool composed - // itself rather than a call anyone was billed for; it must not sit beside the real models. - it("breaks a real session down by model and lists only models", async () => { - await journalClaudeSession(); - await journalCodexSessionBackdated(); - await readEveryTool(); - - const result = await cli([ - "telemetry", - "report", - "--days", - daysBackToTheOldestWork(), - "--json", - ]); - expect(result.exitCode, result.stderr).toBe(0); - const parsed = JSON.parse(result.stdout) as { by_model: readonly { model?: string }[] }; - const models = parsed.by_model.map((row) => row.model); - - expect(models.length).toBeGreaterThan(0); - expect(models).not.toContain(""); - for (const model of models) expect(model).not.toMatch(/^ { - await journalClaudeSession(); - await journalCodexSessionBackdated(); - await readEveryTool(); - const first = await reportEverything(); - - await readEveryTool(); - const second = await reportEverything(); - - expect(second).toBe(first); - }); -}); - -describe("the flow a person can actually follow", () => { - let projectDir: string; - let fakeHome: string; - let binDir: string; - let cleanup: (() => Promise) | undefined; - - beforeEach(async () => { - const env = await createTestEnv("telemetry-flow"); - cleanup = env.cleanup; - projectDir = realpathSync(env.projectDir); - fakeHome = realpathSync(env.fakeHome); - binDir = join(fakeHome, "bin"); - await gitInit(projectDir); - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - "utf-8" - ); - await cp(LOCAL_COST_FIXTURES, fakeHome, { recursive: true }); - await mkdir(binDir, { recursive: true }); - await writeFile(join(binDir, "opencode"), "#!/bin/sh\nexit 1\n", "utf-8"); - await chmod(join(binDir, "opencode"), 0o755); - }); - - afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - }); - - function cli(args: readonly string[]) { - return runCli([...args], projectDir, fakeHome, { - env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` }, - }); - } - - async function journalCodex(): Promise { - const runsDir = join(projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await writeFile( - join(runsDir, `${CODEX_RUN_ID}__${CODEX_SESSION}.jsonl`), - `${JSON.stringify({ - type: "session_start", - at: "2026-07-29T15:10:00Z", - schema_version: 2, - run_id: CODEX_RUN_ID, - tool: "codex", - vendor_id: CODEX_SESSION, - })}\n`, - "utf-8" - ); - } - - it("reads every journalled session without anyone naming one", async () => { - await journalCodex(); - - const read = await cli(["telemetry", "read"]); - - expect(read.exitCode, read.stderr).toBe(0); - expect(read.stdout).toContain("1 session read"); - const report = await cli(["telemetry", "report", "--from", "2026-07-01", "--to", "2026-07-31"]); - expect(report.stdout).toContain("183,939"); - }); - - it("says so, and exits 0, when nothing has been journalled yet", async () => { - const read = await cli(["telemetry", "read"]); - - expect(read.exitCode).toBe(0); - expect(read.stdout).toContain("No session journalled yet"); - }); - - it("answers a program with the same object twice, for the same absolute period", async () => { - await journalCodex(); - await cli(["telemetry", "read"]); - - const first = await cli([ - "telemetry", - "report", - "--from", - "2026-07-01", - "--to", - "2026-07-31", - "--json", - ]); - const second = await cli([ - "telemetry", - "report", - "--from", - "2026-07-01", - "--to", - "2026-07-31", - "--json", - ]); - - expect(first.exitCode, first.stderr).toBe(0); - expect(second.stdout).toBe(first.stdout); - const parsed = JSON.parse(first.stdout); - expect(parsed.cost_report_version).toBe(15); - expect(parsed.period).toEqual({ from_day: "2026-07-01", to_day: "2026-07-31" }); - expect(parsed.attribution.map((row: { attribution: string }) => row.attribution)).toEqual([ - "tool-stated", - "prompt-matched", - "journal-interval", - "unattributed", - ]); - }); - - it("tells a program what each tool can supply, so it never infers it from a missing number", async () => { - const parsed = JSON.parse((await cli(["telemetry", "report", "--json"])).stdout) as { - by_tool: readonly { tool: string; capability: Record }[]; - }; - const capability = Object.fromEntries(parsed.by_tool.map((row) => [row.tool, row.capability])); - - // No locally-read tool carries an amount, and a consumer reads that here rather than - // concluding it from a report that happens to show none. - expect(capability.codex).toMatchObject({ - local_read: { token_counters: true, amount: false, tool_stated_step: false }, - task_attributable: true, - }); - expect(capability.cursor).toMatchObject({ local_read: null, task_attributable: true }); - expect(capability.claude).toMatchObject({ task_attributable: true }); - }); - - it("refuses a period that is not one, naming the flag", async () => { - const result = await cli(["telemetry", "report", "--from", "notaday"]); - - expect(result.exitCode).toBe(1); - expect(`${result.stdout}${result.stderr}`).toContain("--from"); - expect(`${result.stdout}${result.stderr}`).not.toContain("toISOString"); - }); -}); diff --git a/cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts b/cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts deleted file mode 100644 index 90260978d..000000000 --- a/cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -const execFileAsync = promisify(execFile); -const RUNS_ENTRY = "aidd_docs/runs/"; - -async function git(args: readonly string[], cwd: string): Promise<{ stdout: string }> { - return execFileAsync("git", [...args], { cwd, env: environmentWithoutGitVariables(process.env) }); -} - -async function readGitignore(projectDir: string): Promise { - try { - return await readFile(join(projectDir, ".gitignore"), "utf8"); - } catch { - return null; - } -} - -describe("aidd telemetry on carries over what the switch script did beyond flipping a flag", () => { - it("adds the run journal to .gitignore, and nothing wider", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("runs-privacy-gitignore"); - try { - await gitInit(projectDir); - - const result = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - const gitignore = await readGitignore(projectDir); - expect(gitignore?.trim()).toBe(RUNS_ENTRY.trim()); - } finally { - await cleanup(); - } - }); - - it("an existing entry is left as it is, never duplicated", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("runs-privacy-dedupe"); - try { - await gitInit(projectDir); - await writeFile(join(projectDir, ".gitignore"), `node_modules/\n${RUNS_ENTRY}\n`); - - await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - const gitignore = (await readGitignore(projectDir)) ?? ""; - const matches = gitignore.split("\n").filter((line) => line.trim() === RUNS_ENTRY.trim()); - expect(matches.length).toBe(1); - } finally { - await cleanup(); - } - }); - - it("a journal already tracked by git is named once, and nothing is removed or rewritten", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("runs-privacy-tracked"); - try { - await gitInit(projectDir); - await mkdir(join(projectDir, "aidd_docs", "runs"), { recursive: true }); - const trackedRelative = "aidd_docs/runs/old__vendor.jsonl"; - await writeFile(join(projectDir, trackedRelative), '{"type":"session_start"}\n'); - await git(["add", trackedRelative], projectDir); - await git( - [ - "-c", - "user.email=t@t.com", - "-c", - "user.name=t", - "commit", - "-q", - "-m", - "committed by hand", - ], - projectDir - ); - - const result = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stderr).toMatch(/Already tracked by git/u); - expect(result.stderr).toContain(trackedRelative); - const log = await git(["log", "--oneline"], projectDir); - expect(log.stdout.trim().split("\n").length).toBe(1); - } finally { - await cleanup(); - } - }); - - it("nothing extra is said when no journal file is tracked", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("runs-privacy-clean"); - try { - await gitInit(projectDir); - - const result = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stderr).not.toMatch(/Already tracked by git/u); - } finally { - await cleanup(); - } - }); - - it("turning measurement off touches neither .gitignore nor the tracked-file notice", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("runs-privacy-off"); - try { - await gitInit(projectDir); - await mkdir(join(projectDir, "aidd_docs", "runs"), { recursive: true }); - await writeFile(join(projectDir, "aidd_docs", "runs", "old__vendor.jsonl"), "{}\n"); - await git(["add", "aidd_docs/runs/old__vendor.jsonl"], projectDir); - await git( - ["-c", "user.email=t@t.com", "-c", "user.name=t", "commit", "-q", "-m", "committed"], - projectDir - ); - - const result = await runCli(["telemetry", "off"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(await readGitignore(projectDir)).toBeNull(); - expect(result.stdout).not.toMatch(/Already tracked by git/u); - expect(result.stderr).not.toMatch(/Already tracked by git/u); - } finally { - await cleanup(); - } - }); - - it("git add -A stages the .gitignore change and leaves the journal out of the index", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("runs-privacy-add-a"); - try { - await gitInit(projectDir); - - const result = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); - - await mkdir(join(projectDir, "aidd_docs", "runs"), { recursive: true }); - await writeFile( - join(projectDir, "aidd_docs", "runs", "new__session.jsonl"), - '{"type":"session_start"}\n' - ); - - await git(["add", "-A"], projectDir); - const status = await git(["status", "--porcelain"], projectDir); - const staged = status.stdout.trim().split("\n").filter(Boolean); - expect(staged.some((line) => line.endsWith(".gitignore"))).toBe(true); - expect(staged.some((line) => line.includes("aidd_docs/runs"))).toBe(false); - } finally { - await cleanup(); - } - }); - - it("a project outside any git repository still turns on, quietly", async () => { - const dir = await mkdtemp(join(tmpdir(), "aidd-runs-privacy-no-repo-")); - const { fakeHome, cleanup } = await createTestEnv("runs-privacy-no-repo-home"); - try { - const result = await runCli(["telemetry", "on", "--yes"], dir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stderr).toBe(""); - const gitignore = await readGitignore(dir); - expect(gitignore?.trim()).toBe(RUNS_ENTRY.trim()); - } finally { - await cleanup(); - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts b/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts deleted file mode 100644 index ce7b7765f..000000000 --- a/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { readdirSync, readFileSync, realpathSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { copyFixtureTree, pathWithoutAidd, runCli } from "./helpers.js"; - -const REPO_ROOT = REPOSITORY_ROOT; -const JOURNAL_HOOK = join(REPO_ROOT, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); -const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); -const HOOK_FIXTURES = join(REPO_ROOT, "scripts", "__tests__", "fixtures"); - -const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; -const TASK = "2026_08/2026_08_21_probe-task"; -/** Every token the captured Claude Code session billed, the same figure - * `telemetry-lifecycle.e2e.test.ts` pins from the identical fixture and hook sequence. */ -const SESSION_TOKENS = "151,826"; - -describe("the plugin measures on its own", () => { - let projectDir: string; - let fakeHome: string; - let configDir: string; - let tempDir: string; - - beforeEach(async () => { - tempDir = realpathSync(await mkdtemp(join(tmpdir(), "aidd-standalone-"))); - projectDir = join(tempDir, "project"); - fakeHome = join(tempDir, "home"); - configDir = join(tempDir, "config"); - await mkdir(projectDir, { recursive: true }); - await mkdir(fakeHome, { recursive: true }); - execFileSync("git", ["init", "-q", projectDir], { - env: environmentWithoutGitVariables(process.env), - }); - // The tools' own files, exactly as a machine that ran them would hold. - await copyFixtureTree(LOCAL_COST_FIXTURES, fakeHome); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - function env(): NodeJS.ProcessEnv { - return { - ...environmentWithoutGitVariables(process.env), - PATH: pathWithoutAidd(), - HOME: fakeHome, - AIDD_USER_CONFIG_DIR: configDir, - }; - } - - /** The switch is seeded by writing the file, never through `aidd telemetry on`: the claim - * here is that the hooks record with no CLI on the `PATH` this section strips. */ - async function enableTelemetry(): Promise { - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - "utf-8" - ); - } - - async function replayHook(fixture: string, event: string, extra: object): Promise { - const payload = JSON.parse(await readFile(join(HOOK_FIXTURES, `${fixture}.json`), "utf8")); - execFileSync(process.execPath, [JOURNAL_HOOK, event], { - input: JSON.stringify({ - ...payload, - session_id: CLAUDE_SESSION, - transcript_path: join( - fakeHome, - ".claude", - "projects", - "fake-project", - `${CLAUDE_SESSION}.jsonl` - ), - cwd: projectDir, - ...extra, - }), - cwd: projectDir, - env: env(), - }); - } - - // The hooks are plain node so that a session measured now stays readable later, by a CLI - // that was not installed when it ran. - it("journals a whole Claude Code session with no aidd on the path", async () => { - await enableTelemetry(); - await replayHook("claude-code-session-start", "session-start", {}); - await replayHook("claude-code-post-tool-use-skill", "tool-used", { - tool_input: { skill: "aidd-dev:02-implement" }, - }); - - const journals = readdirSync(join(projectDir, "aidd_docs", "runs")).filter((name) => - name.endsWith(".jsonl") - ); - expect(journals).toHaveLength(1); - - const lines = readFileSync(join(projectDir, "aidd_docs", "runs", journals[0] ?? ""), "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { type: string }); - expect(lines.map((line) => line.type)).toContain("session_start"); - expect(lines.map((line) => line.type)).toContain("step_start"); - }); - - // Neither `enableTelemetry` nor `replayHook` invokes the binary: `runCli` below is this - // test's first, and it runs after every write has already happened. - it("reads a session's figures complete, though the CLI did not exist when it ran", async () => { - await enableTelemetry(); - await replayHook("claude-code-session-start", "session-start", {}); - await replayHook("claude-code-post-tool-use-skill", "tool-used", { - tool_input: { skill: "aidd-dev:02-implement" }, - }); - const notes = join( - projectDir, - "aidd_docs", - "tasks", - "2026_08", - "2026_08_21_probe-task", - "notes.md" - ); - await mkdir(dirname(notes), { recursive: true }); - await writeFile(notes, "probe\n", "utf-8"); - await replayHook("claude-code-post-tool-use-write", "tool-used", { - tool_input: { file_path: notes, content: "probe" }, - }); - await replayHook("claude-code-session-start", "turn-end", {}); - - // `read` takes no session identifier: absent one it reads every session the run journal - // knows about, which here is the file the hooks just wrote. - const read = await runCli(["telemetry", "read"], projectDir, fakeHome, { - env: { AIDD_USER_CONFIG_DIR: configDir }, - }); - expect(read.exitCode, read.stderr).toBe(0); - expect(read.stdout).toContain("Claude Code: read"); - - // The captured transcript falls in August 2026, so the period is fixed rather than - // relative to whatever week this suite runs in. - const period = ["--from", "2026-08-01", "--to", "2026-08-31"]; - const reported = await runCli( - ["telemetry", "report", ...period, "--json"], - projectDir, - fakeHome, - { - env: { AIDD_USER_CONFIG_DIR: configDir }, - } - ); - expect(reported.exitCode, reported.stderr).toBe(0); - const envelope = JSON.parse(reported.stdout) as { - totals: { requests: number; input_tokens: number; output_tokens: number }; - by_step: readonly { step?: string }[]; - }; - expect(envelope.totals.requests).toBeGreaterThan(0); - expect(envelope.totals.input_tokens + envelope.totals.output_tokens).toBeGreaterThan(0); - expect(envelope.by_step.map((row) => row.step)).toContain("probe-echo"); - - // The same figures a session recorded with the CLI present yields: they do not shrink - // for having been measured without it. - const reportedText = await runCli(["telemetry", "report", ...period], projectDir, fakeHome, { - env: { AIDD_USER_CONFIG_DIR: configDir }, - }); - expect(reportedText.stdout).toContain(SESSION_TOKENS); - - // Task identity exists only in the journal's own `file_written` line: the transcript - // fixture has no notion of a task folder, so narrowing proves `read` consulted it. - const byTask = await runCli( - ["telemetry", "report", ...period, "--task", TASK], - projectDir, - fakeHome, - { env: { AIDD_USER_CONFIG_DIR: configDir } } - ); - expect(byTask.stdout).toContain(`task ${TASK}`); - expect(byTask.stdout).toContain(SESSION_TOKENS); - }); -}); diff --git a/cli/tests/e2e/telemetry-reference-week.e2e.test.ts b/cli/tests/e2e/telemetry-reference-week.e2e.test.ts deleted file mode 100644 index 76a729873..000000000 --- a/cli/tests/e2e/telemetry-reference-week.e2e.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { cliPath } from "./helpers.js"; - -// The scenario lives in `scripts/lib/telemetry-reference-week.cjs`, shared with -// `scripts/telemetry-reference-week.cjs`, so what the demo prints cannot drift from this. -const week = createRequire(import.meta.url)( - join(REPOSITORY_ROOT, "scripts", "lib", "telemetry-reference-week.cjs") -) as ReferenceWeekModule; - -interface Totals { - readonly requests: number; - readonly cost_micro_usd?: number; - readonly input_tokens?: number; - readonly output_tokens?: number; - readonly cache_read_tokens?: number; - readonly cache_creation_tokens?: number; -} -interface Row { - readonly totals: Totals; -} -interface Envelope { - readonly totals: Totals; - readonly by_day: readonly Row[]; - readonly by_flow: readonly (Row & { readonly flow?: string })[]; - readonly by_step: readonly (Row & { readonly step?: string; readonly attribution: string })[]; - readonly by_task: readonly (Row & { readonly task?: string })[]; - readonly by_backlog: readonly (Row & { readonly backlog?: string })[]; - readonly by_model: readonly (Row & { readonly model?: string })[]; - readonly by_project: readonly (Row & { readonly project?: string })[]; - readonly by_person: readonly (Row & { - readonly resolution: string; - readonly person?: string; - readonly identities: readonly string[]; - })[]; - readonly by_tool: readonly (Row & { - readonly tool: string; - readonly coverage: string; - readonly reason?: string; - readonly session_totals?: Totals; - })[]; -} -interface BuiltWeek { - /** Where the shared destination's records land — the one thing a test may delete to ask - * what `report` can rebuild on its own. */ - readonly sinkDir: string; - readonly expected: { - readonly requests: number; - readonly totalTokens: number; - readonly days: readonly string[]; - readonly models: readonly string[]; - readonly requestTools: readonly string[]; - readonly sessionOnlyTool: string; - readonly uncoveredTools: readonly string[]; - readonly projects: readonly string[]; - readonly people: readonly string[]; - readonly flow: string; - readonly tasks: readonly string[]; - readonly backlogItem: string; - }; -} -interface ReferenceWeekModule { - buildReferenceWeek(options: { root: string; cliPath: string }): BuiltWeek; - reportReferenceWeek(built: BuiltWeek, args?: readonly string[]): string; -} - -function tokensIn(totals: Totals): number { - return ( - (totals.input_tokens ?? 0) + - (totals.output_tokens ?? 0) + - (totals.cache_read_tokens ?? 0) + - (totals.cache_creation_tokens ?? 0) - ); -} - -function sumRequests(rows: readonly Row[]): number { - return rows.reduce((total, row) => total + row.totals.requests, 0); -} - -function sumTokens(rows: readonly Row[]): number { - return rows.reduce((total, row) => total + tokensIn(row.totals), 0); -} - -describe("the reference week", () => { - let root: string; - let built: BuiltWeek; - let envelope: Envelope; - - beforeAll(() => { - root = mkdtempSync(join(tmpdir(), "aidd-reference-week-e2e-")); - built = week.buildReferenceWeek({ root, cliPath: cliPath() }); - envelope = JSON.parse(week.reportReferenceWeek(built, ["--json"])) as Envelope; - }, 120_000); - - afterAll(() => { - if (root) rmSync(root, { recursive: true, force: true }); - }); - - it("counts what the week actually produced", () => { - expect(envelope.totals.requests).toBe(built.expected.requests); - expect(tokensIn(envelope.totals)).toBe(built.expected.totalTokens); - }); - - it("reconciles every breakdown to that same total", () => { - // `by_tool` included: a session-total tool carries its figure in `session_totals`, a field - // of its own, so `totals` still sums like every other axis. - const breakdowns = [ - envelope.by_day, - envelope.by_flow, - envelope.by_step, - envelope.by_task, - envelope.by_backlog, - envelope.by_model, - envelope.by_tool, - envelope.by_project, - envelope.by_person, - ]; - for (const rows of breakdowns) { - expect(sumRequests(rows)).toBe(envelope.totals.requests); - expect(sumTokens(rows)).toBe(tokensIn(envelope.totals)); - } - }); - - it("breaks the week down by the flow that ran, keeping what ran outside one apart", () => { - const named = envelope.by_flow.filter((row) => row.flow !== undefined); - expect(named.map((row) => row.flow)).toEqual([built.expected.flow]); - expect(named[0]?.totals.requests).toBe(3); - - const outside = envelope.by_flow.filter((row) => row.flow === undefined); - expect(outside).toHaveLength(1); - expect(outside[0]?.totals.requests).toBe(4); - }); - - it("leads with the run it can name, though more of the week fell outside every flow", () => { - // The week's own figures: 4 requests outside every flow against 3 inside the one run, so - // ordering by size alone would put the unnamed remainder first. - expect(envelope.by_flow.map((row) => row.flow)).toEqual([built.expected.flow, undefined]); - }); - - it("prints, beside those figures, the two things this axis cannot tell apart", () => { - const rendered = week.reportReferenceWeek(built, ["--axis", "flow"]); - - expect(rendered).toContain("a skill run by hand while a flow was open is counted inside it"); - expect(rendered).toContain("00-async-dev, 01-sdlc or 02-backlog"); - }); - - it("names each step's attribution, all three strengths in one week", () => { - const strengths = new Set(envelope.by_step.map((row) => row.attribution)); - expect(strengths).toEqual(new Set(["tool-stated", "journal-interval", "unattributed"])); - }); - - it("breaks the week down by task, and by the backlog item a task declared", () => { - // Distinct: one task can hold two rows — one for what a declaration covered, one for what - // only a written file names — so this asserts which tasks the week names, not how many. - const tasks = [...new Set(envelope.by_task.map((row) => row.task).filter(Boolean))]; - expect(tasks.sort()).toEqual([...built.expected.tasks].sort()); - - const declared = envelope.by_backlog.filter((row) => row.backlog !== undefined); - expect(declared.map((row) => row.backlog)).toEqual([built.expected.backlogItem]); - // Three requests, not two: that session's 08:07 record precedes its own declaration at - // 08:12, its journal witnessed it, and it wrote into that one task folder alone. - expect(declared[0]?.totals.requests).toBe(3); - expect(sumRequests(envelope.by_backlog)).toBe(envelope.totals.requests); - }); - - it("keeps a session-total tool out of the request totals and still shows its figure", () => { - const byId = new Map(envelope.by_tool.map((row) => [row.tool, row])); - - for (const tool of built.expected.requestTools) { - expect(byId.get(tool)?.totals.requests).toBeGreaterThan(0); - } - const sessionOnly = byId.get(built.expected.sessionOnlyTool); - expect(sessionOnly?.totals.requests).toBe(0); - expect(tokensIn(sessionOnly?.session_totals ?? { requests: 0 })).toBeGreaterThan(0); - }); - - it("names every tool it cannot read, with the reason, rather than omitting it", () => { - const byId = new Map(envelope.by_tool.map((row) => [row.tool, row])); - for (const tool of built.expected.uncoveredTools) { - const row = byId.get(tool); - expect(row, `${tool} is missing from by_tool`).toBeDefined(); - expect(row?.totals.requests).toBe(0); - expect(row?.reason).toBeTruthy(); - } - }); - - it("splits the week by person and by project, without either standing in for the other", () => { - expect(envelope.by_person.flatMap((row) => row.identities).sort()).toEqual( - [...built.expected.people].sort() - ); - expect(envelope.by_project.map((row) => row.project).sort()).toEqual( - [...built.expected.projects].sort() - ); - }); - - it("names a teammate's records as an identity it cannot resolve, never as nobody", () => { - // The report runs on Ada's machine: Bo's identifier is real and his records are here, but - // nothing local maps it to a person, so it gets its own row named for what is known. - const resolutions = envelope.by_person.map((row) => row.resolution); - expect(resolutions).toContain("mapped"); - expect(resolutions).toContain("unresolved"); - }); - - it("gives every day of the period a row, and only the worked days a figure", () => { - const worked = envelope.by_day.filter((row) => row.totals.requests > 0); - expect(worked).toHaveLength(built.expected.days.length); - }); - - it("states no amount anywhere, because no tool supplies one", () => { - expect(envelope.totals.cost_micro_usd).toBeUndefined(); - const printed = week.reportReferenceWeek(built, ["--axis", "total"]); - expect(printed).toContain("amount unknown"); - expect(printed).not.toContain("$"); - }); -}); - -// `report` reads the sink, and a week nobody ran `aidd telemetry read` over used to answer -// "nothing in this period" — indistinguishable from a week where nothing was spent. -describe("a report that needs no read first", () => { - let root: string; - let built: BuiltWeek; - - beforeAll(() => { - root = mkdtempSync(join(tmpdir(), "aidd-reference-week-catchup-")); - built = week.buildReferenceWeek({ root, cliPath: cliPath() }); - }, 120_000); - - afterAll(() => { - if (root) rmSync(root, { recursive: true, force: true }); - }); - - it("answers with figures on a sink nobody has filled", () => { - // Everything the week stored, thrown away: only what `report` reads back for itself can - // account for what comes out below. - rmSync(join(built.sinkDir, "telemetry"), { recursive: true, force: true }); - - const envelope = JSON.parse(week.reportReferenceWeek(built, ["--json"])) as Envelope; - - // Ada's own four, and only those: the report runs under her home, and Bo's session files - // are under his. Catching up reads what this machine can read — never what it cannot. - expect(envelope.totals.requests).toBe(4); - expect(sumRequests(envelope.by_day)).toBe(4); - expect(sumRequests(envelope.by_flow)).toBe(4); - expect(sumRequests(envelope.by_task)).toBe(4); - }); -}); - -// Git exports `GIT_DIR` into every process it spawns, so a suite run from inside a hook inherits -// a pointer to the real repository and the builder's own `git init` lands somewhere else. -describe("the week builds inside a git hook's own environment", () => { - it("ignores a leaked GIT_DIR rather than resolving the real repository", () => { - const root = mkdtempSync(join(tmpdir(), "aidd-reference-week-gitdir-")); - const saved = process.env.GIT_DIR; - process.env.GIT_DIR = join(REPOSITORY_ROOT, ".git"); - try { - const built = week.buildReferenceWeek({ root, cliPath: cliPath() }); - const envelope = JSON.parse(week.reportReferenceWeek(built, ["--json"])) as Envelope; - - expect(envelope.totals.requests).toBe(built.expected.requests); - } finally { - if (saved === undefined) delete process.env.GIT_DIR; - else process.env.GIT_DIR = saved; - rmSync(root, { recursive: true, force: true }); - } - }, 120_000); -}); diff --git a/cli/tests/e2e/telemetry-refusal.e2e.test.ts b/cli/tests/e2e/telemetry-refusal.e2e.test.ts deleted file mode 100644 index e38079afd..000000000 --- a/cli/tests/e2e/telemetry-refusal.e2e.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { execFile, execFileSync } from "node:child_process"; -import { readdir, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; -import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -const execFileAsync = promisify(execFile); -const REPO_ROOT = REPOSITORY_ROOT; -const JOURNAL_HOOK = resolve(REPO_ROOT, "plugins/aidd-telemetry/hooks/journal.cjs"); - -describe("a person's own refusal, without touching a tracked file", () => { - function hookEnv(fakeHome: string, extra?: Record): NodeJS.ProcessEnv { - return { ...environmentWithoutGitVariables(process.env), HOME: fakeHome, ...extra }; - } - - function journal( - projectDir: string, - fakeHome: string, - sessionId: string, - extra?: Record - ): void { - execFileSync(process.execPath, [JOURNAL_HOOK, "session-start"], { - input: JSON.stringify({ - session_id: sessionId, - hook_event_name: "SessionStart", - cwd: projectDir, - transcript_path: `${fakeHome}/.claude/projects/fake/${sessionId}.jsonl`, - }), - cwd: projectDir, - env: hookEnv(fakeHome, extra), - }); - } - - async function runFileCount(projectDir: string): Promise { - try { - const files = await readdir(`${projectDir}/aidd_docs/runs`); - return files.filter((f) => f.endsWith(".jsonl")).length; - } catch { - return 0; - } - } - - async function gitStatus(projectDir: string): Promise { - const { stdout } = await execFileAsync("git", ["status", "--porcelain=v1"], { - cwd: projectDir, - env: environmentWithoutGitVariables(process.env), - }); - return stdout; - } - - it("refusing in this person's own environment records nothing, in a project whose tracked configuration allows it", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("refusal-basic"); - try { - await gitInit(projectDir); - const on = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - expect(on.exitCode, on.stderr).toBe(0); - - const before = await gitStatus(projectDir); - journal(projectDir, fakeHome, "10000000-0000-4000-8000-000000000001", { - AIDD_TELEMETRY: "0", - }); - - expect(await runFileCount(projectDir)).toBe(0); - const after = await gitStatus(projectDir); - expect(after).toBe(before); - } finally { - await cleanup(); - } - }); - - it("an unset refusal turns nothing on by itself, and a project with measurement on still records", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("refusal-unset"); - try { - await gitInit(projectDir); - await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - journal(projectDir, fakeHome, "10000000-0000-4000-8000-000000000002"); - - expect(await runFileCount(projectDir)).toBe(1); - } finally { - await cleanup(); - } - }); - - it("removing the refusal records again, in the same project whose switch never changed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("refusal-removed"); - try { - await gitInit(projectDir); - await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - journal(projectDir, fakeHome, "10000000-0000-4000-8000-000000000003", { - AIDD_TELEMETRY: "0", - }); - expect(await runFileCount(projectDir)).toBe(0); - - journal(projectDir, fakeHome, "10000000-0000-4000-8000-000000000004"); - expect(await runFileCount(projectDir)).toBe(1); - } finally { - await cleanup(); - } - }); - - it("the refusal wins over a project that turns measurement on, never the file", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("refusal-wins"); - try { - await gitInit(projectDir); - const on = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - expect(on.exitCode, on.stderr).toBe(0); - - journal(projectDir, fakeHome, "10000000-0000-4000-8000-000000000005", { - AIDD_TELEMETRY: "0", - }); - - expect(await runFileCount(projectDir)).toBe(0); - } finally { - await cleanup(); - } - }); -}); - -describe("turning measurement on for everyone who clones is confirmed", () => { - it("without --yes, refuses and writes nothing, naming the consequence", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("on-requires-yes"); - try { - await gitInit(projectDir); - - const result = await runCli(["telemetry", "on"], projectDir, fakeHome); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("--yes"); - expect(result.stderr).toContain("everyone who clones"); - await expect(readFile(`${projectDir}/.aidd/config.json`, "utf8")).rejects.toThrow(); - } finally { - await cleanup(); - } - }); - - it("confirmed with --yes, writes the switch and says what was done", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("on-yes-confirmed"); - try { - await gitInit(projectDir); - - const result = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("everyone who clones"); - const written = JSON.parse(await readFile(`${projectDir}/.aidd/config.json`, "utf8")); - expect(written.telemetry).toEqual({ enabled: true }); - } finally { - await cleanup(); - } - }); - - it("turning it off needs no confirmation", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("off-no-confirm"); - try { - await gitInit(projectDir); - await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - const off = await runCli(["telemetry", "off"], projectDir, fakeHome); - - expect(off.exitCode, off.stderr).toBe(0); - const written = JSON.parse(await readFile(`${projectDir}/.aidd/config.json`, "utf8")); - expect(written.telemetry.enabled).toBe(false); - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/telemetry-report.e2e.test.ts b/cli/tests/e2e/telemetry-report.e2e.test.ts deleted file mode 100644 index a9ecc3ef4..000000000 --- a/cli/tests/e2e/telemetry-report.e2e.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -/** Seeded straight into the sink: going through the read would depend on whether the machine - * has OpenCode installed. Their July moments sit in a day file named for a much later day. */ -const CODEX_RECORDS = [ - { - kind: "request", - vendor_id: "019fae6f-2009-7cd3-86b2-b8f83481b160", - vendor_field: "session_meta.id", - turn_id: "019fae6f-2084-7d63-b3c1-3d45d0864fe9", - turn_field: "turn_id", - model: "gpt-5.6-sol", - effort: "high", - event_timestamp: "2026-07-29T15:12:27.889Z", - input_tokens: 8898, - output_tokens: 827, - cache_read_tokens: 65792, - cache_creation_tokens: 0, - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - step_attribution: "unattributed", - }, - { - kind: "request", - vendor_id: "019fae6f-2009-7cd3-86b2-b8f83481b160", - vendor_field: "session_meta.id", - turn_id: "019fae71-ae8b-7850-a982-78d7cd9dba52", - turn_field: "turn_id", - model: "gpt-5.6-sol", - effort: "high", - event_timestamp: "2026-07-29T15:15:13.692Z", - input_tokens: 5032, - output_tokens: 3550, - cache_read_tokens: 99840, - cache_creation_tokens: 0, - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - step_attribution: "unattributed", - }, -] as const; - -/** Three records the filter tests narrow over: two inside the period, one carrying a model no - * in-period record names — known to a sweep of the file, idle in every in-period selection. */ -const FILTER_RECORDS = [ - { - kind: "request", - vendor_id: "f-1", - vendor_field: "session_meta.id", - turn_id: "f-turn-1", - turn_field: "turn_id", - model: "gpt-5.6-sol", - event_timestamp: "2026-07-29T15:12:27.889Z", - input_tokens: 100, - output_tokens: 10, - cache_read_tokens: 0, - cache_creation_tokens: 0, - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - step_attribution: "tool-stated", - step: "implement", - project_id: "acme/widgets", - }, - { - kind: "request", - vendor_id: "f-2", - vendor_field: "session_meta.id", - turn_id: "f-turn-2", - turn_field: "turn_id", - model: "gpt-4", - event_timestamp: "2026-07-29T15:20:00.000Z", - input_tokens: 50, - output_tokens: 5, - cache_read_tokens: 0, - cache_creation_tokens: 0, - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - step_attribution: "unattributed", - project_id: "acme/gadgets", - }, - { - kind: "request", - vendor_id: "f-3", - vendor_field: "session_meta.id", - turn_id: "f-turn-3", - turn_field: "turn_id", - model: "haiku", - // Outside every period these tests ask for - the sink still opens this file to build - // its "ever seen" sets, so "haiku" reads as known without ever being in scope. - event_timestamp: "2020-01-01T00:00:00.000Z", - input_tokens: 1, - output_tokens: 1, - cache_read_tokens: 0, - cache_creation_tokens: 0, - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - step_attribution: "unattributed", - project_id: "acme/old-project", - }, -] as const; - -const SINK_DAY_FILE = "2026-08-21.jsonl"; -const CODEX_WORK_DAY = "2026-07-29"; -const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - -describe("aidd telemetry report", () => { - let cleanup: (() => Promise) | undefined; - - afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - }); - - async function seed( - records: readonly unknown[] = [] - ): Promise<{ projectDir: string; fakeHome: string }> { - const env = await createTestEnv("telemetry-report"); - cleanup = env.cleanup; - await gitInit(env.projectDir); - await mkdir(join(env.projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(env.projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - "utf-8" - ); - if (records.length > 0) await seedSink(env.fakeHome, records); - return env; - } - - async function seedSink(fakeHome: string, records: readonly unknown[]): Promise { - const sinkDir = join(fakeHome, ".config", "aidd", "telemetry"); - await mkdir(sinkDir, { recursive: true }); - await writeFile( - join(sinkDir, SINK_DAY_FILE), - `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, - "utf-8" - ); - } - - /** Wide enough to reach the day the work happened, whatever day it was stored on. */ - function daysBackToTheWork(): string { - const elapsed = Date.now() - Date.parse(`${CODEX_WORK_DAY}T00:00:00Z`); - return String(Math.ceil(elapsed / MILLISECONDS_PER_DAY)); - } - - it("prints nothing measured and exits 0 for a period holding nothing", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report"], projectDir, fakeHome); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("nothing in this period"); - }); - - it("reports what a real session consumed", async () => { - const { projectDir, fakeHome } = await seed(CODEX_RECORDS); - - const result = await runCli( - ["telemetry", "report", "--days", daysBackToTheWork()], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(0); - // Recomputed by hand from the rollout's own `last_token_usage` increments, not from - // anything this codebase produces: 75,517 for one turn and 108,422 for the other. - expect(result.stdout).toContain("183,939"); - // Codex's own files carry no dollar figure; a zero here would read as free. - expect(result.stdout).toContain("amount unknown"); - expect(result.stdout).not.toContain("$0.00"); - }); - - it("leaves work outside the period out of it, however recently it was stored", async () => { - const { projectDir, fakeHome } = await seed(CODEX_RECORDS); - - // The default period ends today and reaches back a week — nowhere near July, though - // the day file these records live in is named for a much later day than they happened. - const result = await runCli(["telemetry", "report"], projectDir, fakeHome); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("nothing in this period"); - expect(result.stdout).not.toContain("183,939"); - }); - - it("names every tool that cannot be read, with its own reason", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report"], projectDir, fakeHome); - - expect(result.stdout).toContain("Cursor"); - expect(result.stdout).toContain("not covered"); - expect(result.stdout).toContain("GitHub Copilot"); - }); - - it("refuses a period that is not a whole number of days, naming the flag", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", "--days", "0"], projectDir, fakeHome); - - expect(result.exitCode).toBe(1); - expect(`${result.stdout}${result.stderr}`).toContain("--days"); - }); - - it("keeps only the project asked for, saying so in the object it answers with", async () => { - const { projectDir, fakeHome } = await seed(FILTER_RECORDS); - - const result = await runCli( - ["telemetry", "report", "--days", daysBackToTheWork(), "--project", "acme/widgets", "--json"], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout); - expect(envelope.filters).toEqual({ project: "acme/widgets" }); - expect(envelope.totals.requests).toBe(1); - expect(envelope.by_project).toEqual([{ project: "acme/widgets", totals: envelope.totals }]); - }); - - it("narrows two filters to their intersection, project as filter and step as axis", async () => { - const { projectDir, fakeHome } = await seed(FILTER_RECORDS); - - const result = await runCli( - [ - "telemetry", - "report", - "--days", - daysBackToTheWork(), - "--project", - "acme/widgets", - "--model", - "gpt-5.6-sol", - "--json", - ], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout); - expect(envelope.totals.requests).toBe(1); - expect(envelope.by_step).toEqual([ - { step: "implement", attribution: "tool-stated", totals: envelope.totals }, - ]); - }); - - it("names a project nobody ever worked in, apart from a total of zero", async () => { - const { projectDir, fakeHome } = await seed(FILTER_RECORDS); - - const result = await runCli( - [ - "telemetry", - "report", - "--days", - daysBackToTheWork(), - "--project", - "never-worked-here", - "--json", - ], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout); - expect(envelope.empty_selection).toEqual({ - filter: "project", - value: "never-worked-here", - known: false, - }); - expect(result.stdout).not.toContain('"cost_micro_usd"'); - }); - - it("tells a known value idle in this period apart from one never seen at all", async () => { - const { projectDir, fakeHome } = await seed(FILTER_RECORDS); - - const result = await runCli( - ["telemetry", "report", "--days", daysBackToTheWork(), "--model", "haiku"], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("known, but no work here"); - expect(result.stdout).not.toContain("never named this"); - }); - - it("prints the composed selection in the header a person reads", async () => { - const { projectDir, fakeHome } = await seed(FILTER_RECORDS); - - const result = await runCli( - [ - "telemetry", - "report", - "--days", - daysBackToTheWork(), - "--project", - "acme/widgets", - "--step", - "implement", - ], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("filters: project=acme/widgets, step=implement"); - }); -}); diff --git a/cli/tests/e2e/telemetry-six-questions.e2e.test.ts b/cli/tests/e2e/telemetry-six-questions.e2e.test.ts deleted file mode 100644 index 74d6889ce..000000000 --- a/cli/tests/e2e/telemetry-six-questions.e2e.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -/** - * One session, journalled with two declared tasks back to back plus a record predating - * both, so `by_task` has a named row per task and a row for what fell in no interval. - */ -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; -const VENDOR_ID = "22222222-2222-4222-8222-222222222222"; -const PROJECT_ID = "acme/widgets"; -const ALPHA_TASK = "2026_01/2026_01_10_alpha"; -const BETA_TASK = "2026_01/2026_01_10_beta"; -const PERIOD = ["--from", "2026-01-01", "--to", "2026-01-31"]; - -const JOURNAL_LINES = [ - { - type: "session_start", - at: "2026-01-10T08:00:00Z", - run_id: RUN_ID, - tool: "codex", - vendor_id: VENDOR_ID, - project_id: PROJECT_ID, - }, - { - type: "task_declared", - at: "2026-01-10T09:00:00Z", - path: `aidd_docs/tasks/${ALPHA_TASK}/spec.md`, - }, - { type: "turn_end", at: "2026-01-10T10:00:00Z" }, - { - type: "task_declared", - at: "2026-01-10T10:00:00Z", - path: `aidd_docs/tasks/${BETA_TASK}/spec.md`, - }, - { type: "turn_end", at: "2026-01-10T11:00:00Z" }, -]; - -function record(overrides: Record): Record { - return { - kind: "request", - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - vendor_id: VENDOR_ID, - vendor_field: "session_meta.id", - step_attribution: "tool-stated", - project_id: PROJECT_ID, - ...overrides, - }; -} - -// Before any declaration - lands in no known task. Cost 1. -const BEFORE_ANY_DECLARATION = record({ - turn_id: "before", - event_timestamp: "2026-01-10T08:30:00Z", - cost_usd: 1, - model: "gpt-5", - step: "aidd-dev:02-implement", - person_id: "person-a", -}); -// Inside the alpha interval. Cost 2. -const DURING_ALPHA = record({ - turn_id: "alpha", - event_timestamp: "2026-01-10T09:30:00Z", - cost_usd: 2, - model: "gpt-5", - step: "aidd-dev:02-implement", - person_id: "person-a", -}); -// Inside the beta interval. Cost 4. -const DURING_BETA = record({ - turn_id: "beta", - event_timestamp: "2026-01-10T10:30:00Z", - cost_usd: 4, - model: "haiku", - step: "aidd-dev:05-review", - person_id: "person-b", -}); -const RECORDS = [BEFORE_ANY_DECLARATION, DURING_ALPHA, DURING_BETA]; - -interface Totals { - readonly requests: number; - readonly cost_micro_usd?: number; -} - -interface Envelope { - readonly totals: Totals; - readonly by_model: readonly { readonly totals: Totals }[]; - readonly by_task: readonly { readonly task?: string; readonly totals: Totals }[]; - readonly by_step: readonly { readonly totals: Totals }[]; - readonly by_person: readonly { readonly totals: Totals }[]; - readonly by_project: readonly { readonly totals: Totals }[]; -} - -function sumRequests(rows: readonly { readonly totals: Totals }[]): number { - return rows.reduce((sum, row) => sum + row.totals.requests, 0); -} - -function sumCost(rows: readonly { readonly totals: Totals }[]): number { - return rows.reduce((sum, row) => sum + (row.totals.cost_micro_usd ?? 0), 0); -} - -describe("aidd telemetry report — the six questions, over one period", () => { - let cleanup: (() => Promise) | undefined; - - afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - }); - - async function seed(): Promise<{ projectDir: string; fakeHome: string }> { - const env = await createTestEnv("telemetry-six-questions"); - cleanup = env.cleanup; - await gitInit(env.projectDir); - await mkdir(join(env.projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(env.projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - "utf-8" - ); - const runsDir = join(env.projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await writeFile( - join(runsDir, `${RUN_ID}__${VENDOR_ID}.jsonl`), - `${JOURNAL_LINES.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8" - ); - const sinkDir = join(env.fakeHome, ".config", "aidd", "telemetry"); - await mkdir(sinkDir, { recursive: true }); - await writeFile( - join(sinkDir, "2026-01-31.jsonl"), - `${RECORDS.map((r) => JSON.stringify(r)).join("\n")}\n`, - "utf-8" - ); - return env; - } - - it("answers total, by model, by task, by step, by person and by project, all reconciling", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout) as Envelope; - - expect(envelope.totals.requests).toBe(3); - expect(envelope.totals.cost_micro_usd).toBe(7_000_000); - - for (const rows of [ - envelope.by_model, - envelope.by_task, - envelope.by_step, - envelope.by_person, - envelope.by_project, - ]) { - expect(sumRequests(rows)).toBe(envelope.totals.requests); - expect(sumCost(rows)).toBe(envelope.totals.cost_micro_usd); - } - - const taskNames = envelope.by_task.map((row) => row.task); - expect(taskNames).toContain(ALPHA_TASK); - expect(taskNames).toContain(BETA_TASK); - expect(taskNames).toContain(undefined); - expect(envelope.by_task).toHaveLength(3); - }); - - it("prints every axis through --axis, each stating the same total it belongs to", async () => { - const { projectDir, fakeHome } = await seed(); - - for (const axis of ["total", "model", "task", "step", "person", "project"]) { - const result = await runCli( - ["telemetry", "report", ...PERIOD, "--axis", axis], - projectDir, - fakeHome - ); - expect(result.exitCode, `--axis ${axis}: ${result.stderr}`).toBe(0); - expect(result.stdout).toContain(`axis: ${axis === "total" ? "total" : `by ${axis}`}`); - } - }); - - it("names the no-task row for what is known, never for what is guessed", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli( - ["telemetry", "report", ...PERIOD, "--axis", "task"], - projectDir, - fakeHome - ); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(ALPHA_TASK); - expect(result.stdout).toContain(BETA_TASK); - // BEFORE_ANY_DECLARATION's moment (08:30) precedes alpha's declaration (09:00), which - // is one of the three named reasons a row carries no task. - expect(result.stdout).toContain("before the next task this session declares"); - }); -}); diff --git a/cli/tests/e2e/telemetry-stored-export-record.e2e.test.ts b/cli/tests/e2e/telemetry-stored-export-record.e2e.test.ts deleted file mode 100644 index 815ca2cab..000000000 --- a/cli/tests/e2e/telemetry-stored-export-record.e2e.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -/** - * A record an earlier version of this tool wrote with `provenance: "export"` must stay - * readable, countable and reportable: the writer is gone, the reader is not. - */ -const SINK_DAY_FILE = "2026-08-18.jsonl"; -const WORK_DAY = "2026-08-18"; - -const STORED_EXPORT_RECORD = { - sink_schema_version: 2, - kind: "request", - provenance: "export", - tool: "claude", - vendor_id: "7c53f826-fc3e-4729-8e2b-2cba887d3926", - vendor_field: "session.id", - turn_id: "a4b7b0b6-dc16-4889-b25a-def1d207aec9", - turn_field: "prompt.id", - step_attribution: "unattributed", - project_id: "aidd-lab/telemetry-proof", - billed_request_id: "req_011CeAaRe8Mm7oS7xvfjDPw8", - cost_usd: 0.0132201, - input_tokens: 2, - output_tokens: 4, - cache_read_tokens: 43847, - cache_creation_tokens: 0, - model: "claude-sonnet-5", - event_timestamp: "2026-08-18T17:04:39.258Z", -} as const; - -async function seedSink(fakeHome: string, records: readonly unknown[]): Promise { - const sinkDir = join(fakeHome, ".config", "aidd", "telemetry"); - await mkdir(sinkDir, { recursive: true }); - await writeFile( - join(sinkDir, SINK_DAY_FILE), - `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, - "utf-8" - ); -} - -function daysBackToTheWork(): string { - const elapsed = Date.now() - Date.parse(`${WORK_DAY}T00:00:00Z`); - return String(Math.ceil(elapsed / (24 * 60 * 60 * 1000))); -} - -describe("a record the removed export route already wrote stays readable", () => { - it("is counted by `aidd telemetry report`, with its own figures", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("stored-export-record"); - try { - await gitInit(projectDir); - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }) - ); - await seedSink(fakeHome, [STORED_EXPORT_RECORD]); - - const result = await runCli( - ["telemetry", "report", "--days", daysBackToTheWork(), "--json"], - projectDir, - fakeHome - ); - - expect(result.exitCode, result.stderr).toBe(0); - const envelope = JSON.parse(result.stdout); - expect(envelope.totals.requests).toBe(1); - expect(envelope.totals.input_tokens).toBe(2); - expect(envelope.totals.output_tokens).toBe(4); - expect(envelope.totals.cache_read_tokens).toBe(43847); - // The one route that ever carried an amount — kept exactly as the stored line has it. - expect(envelope.totals.cost_micro_usd).toBe(13220); - } finally { - await cleanup(); - } - }); - - it("counts once, not twice, when the same billed call also has a local-read sibling", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("stored-export-record-collapse"); - try { - await gitInit(projectDir); - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }) - ); - const localSibling = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: STORED_EXPORT_RECORD.vendor_id, - vendor_field: "sessionId", - turn_id: STORED_EXPORT_RECORD.billed_request_id, - turn_field: "requestId", - billed_request_id: STORED_EXPORT_RECORD.billed_request_id, - step_attribution: "tool-stated", - step: "aidd-dev:02-implement", - input_tokens: STORED_EXPORT_RECORD.input_tokens, - output_tokens: STORED_EXPORT_RECORD.output_tokens, - model: STORED_EXPORT_RECORD.model, - event_timestamp: STORED_EXPORT_RECORD.event_timestamp, - }; - await seedSink(fakeHome, [STORED_EXPORT_RECORD, localSibling]); - - const result = await runCli( - ["telemetry", "report", "--days", daysBackToTheWork(), "--json"], - projectDir, - fakeHome - ); - - expect(result.exitCode, result.stderr).toBe(0); - const envelope = JSON.parse(result.stdout); - // One billed call, seen by both routes, counts once — holding for an export-provenance - // record read from disk. - expect(envelope.totals.requests).toBe(1); - expect(envelope.totals.input_tokens).toBe(2); - expect(envelope.totals.cost_micro_usd).toBe(13220); - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/telemetry-task-midsession.e2e.test.ts b/cli/tests/e2e/telemetry-task-midsession.e2e.test.ts deleted file mode 100644 index a6ea6ab27..000000000 --- a/cli/tests/e2e/telemetry-task-midsession.e2e.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -/** - * The journals here never end with a `turn_end`: the session the happy path reads from is - * still running when the report is asked for, an unclosed declared interval throughout. - */ -const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAX"; -const ALPHA_VENDOR_ID = "33333333-3333-4333-8333-333333333333"; -const SILENT_RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAY"; -const SILENT_VENDOR_ID = "44444444-4444-4444-8444-444444444444"; -const AMBIGUOUS_RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAZ"; -const AMBIGUOUS_VENDOR_ID = "55555555-5555-4555-8555-555555555555"; -const PROJECT_ID = "acme/widgets"; -const ALPHA_TASK = "2026_02/2026_02_10_alpha"; -const PERIOD = ["--from", "2026-02-01", "--to", "2026-02-28"]; - -const SESSION_START_AT = "2026-02-10T09:00:00Z"; -const TASK_DECLARED_AT = "2026-02-10T09:10:00Z"; -// The journal's only witness that the session kept going after the declaration - a written -// file, no `turn_end` anywhere in this session's journal. -const FILE_WRITTEN_AT = "2026-02-10T09:40:00Z"; - -/** No `turn_end` at all: the session this journal describes has not ended when the report - * below reads it. */ -const ALPHA_JOURNAL_LINES = [ - { - type: "session_start", - at: SESSION_START_AT, - run_id: RUN_ID, - tool: "codex", - vendor_id: ALPHA_VENDOR_ID, - project_id: PROJECT_ID, - }, - { - type: "task_declared", - at: TASK_DECLARED_AT, - path: `aidd_docs/tasks/${ALPHA_TASK}/spec.md`, - }, - { - type: "file_written", - at: FILE_WRITTEN_AT, - path: `aidd_docs/tasks/${ALPHA_TASK}/plan.md`, - }, -]; - -/** A second, wholly separate session whose journal never names a task at all - the - * "no-declaration" reason lives here, never mixed into the session that does declare. */ -const SILENT_JOURNAL_LINES = [ - { - type: "session_start", - at: "2026-02-10T12:00:00Z", - run_id: SILENT_RUN_ID, - tool: "codex", - vendor_id: SILENT_VENDOR_ID, - project_id: PROJECT_ID, - }, -]; - -/** A third session that declares a task and writes into TWO task folders: two candidates - * and no reason to choose, so the written-file route infers nothing here. */ -const AMBIGUOUS_JOURNAL_LINES = [ - { - type: "session_start", - at: "2026-02-10T14:00:00Z", - run_id: AMBIGUOUS_RUN_ID, - tool: "codex", - vendor_id: AMBIGUOUS_VENDOR_ID, - project_id: PROJECT_ID, - }, - { - type: "file_written", - at: "2026-02-10T14:10:00Z", - path: `aidd_docs/tasks/${ALPHA_TASK}/plan.md`, - }, - { - type: "file_written", - at: "2026-02-10T14:12:00Z", - path: "aidd_docs/tasks/2026_02/2026_02_10_beta/plan.md", - }, - { - type: "task_declared", - at: "2026-02-10T14:30:00Z", - path: `aidd_docs/tasks/${ALPHA_TASK}/spec.md`, - }, -]; - -function record(overrides: Record): Record { - return { - kind: "request", - sink_schema_version: 2, - provenance: "local-read", - tool: "codex", - vendor_field: "session_meta.id", - step_attribution: "unattributed", - project_id: PROJECT_ID, - ...overrides, - }; -} - -// Before the declaration - precedes-declaration. -const BEFORE_DECLARATION = record({ - vendor_id: ALPHA_VENDOR_ID, - turn_id: "before", - event_timestamp: "2026-02-10T09:05:00Z", - cost_usd: 1, -}); -// After the declaration, before the write. No `turn_end` has been written anywhere in -// this session's journal at this point. -const DURING_ALPHA_NO_TURN_END = record({ - vendor_id: ALPHA_VENDOR_ID, - turn_id: "during", - event_timestamp: "2026-02-10T09:20:00Z", - cost_usd: 2, -}); -// After the last thing the journal witnessed (the write at 09:40) - journal-silent, never -// attributed however long the silence that follows a declaration runs. -const AFTER_LAST_WITNESS = record({ - vendor_id: ALPHA_VENDOR_ID, - turn_id: "after", - event_timestamp: "2026-02-10T10:30:00Z", - cost_usd: 4, -}); -// Before the ambiguous session's own declaration, and its two written folders refuse the -// inferred route - precedes-declaration. -const PRECEDES_IN_AMBIGUOUS = record({ - vendor_id: AMBIGUOUS_VENDOR_ID, - turn_id: "ambiguous", - event_timestamp: "2026-02-10T14:05:00Z", - cost_usd: 16, -}); -// A session whose journal never declared a task at all - no-declaration. -const NEVER_DECLARED = record({ - vendor_id: SILENT_VENDOR_ID, - turn_id: "silent", - event_timestamp: "2026-02-10T12:05:00Z", - cost_usd: 8, -}); - -const RECORDS = [ - BEFORE_DECLARATION, - DURING_ALPHA_NO_TURN_END, - AFTER_LAST_WITNESS, - PRECEDES_IN_AMBIGUOUS, - NEVER_DECLARED, -]; - -interface TaskRow { - readonly task?: string; - readonly attribution?: string; - readonly reason?: string; - readonly totals: { readonly requests: number; readonly cost_micro_usd?: number }; -} - -interface Envelope { - readonly by_task: readonly TaskRow[]; -} - -describe("aidd telemetry report — a task declared while the work is still going", () => { - let cleanup: (() => Promise) | undefined; - - afterEach(async () => { - await cleanup?.(); - cleanup = undefined; - }); - - async function seed(): Promise<{ - projectDir: string; - fakeHome: string; - }> { - const env = await createTestEnv("telemetry-task-midsession"); - cleanup = env.cleanup; - await gitInit(env.projectDir); - await mkdir(join(env.projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(env.projectDir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - "utf-8" - ); - const runsDir = join(env.projectDir, "aidd_docs", "runs"); - await mkdir(runsDir, { recursive: true }); - await writeFile( - join(runsDir, `${RUN_ID}__${ALPHA_VENDOR_ID}.jsonl`), - `${ALPHA_JOURNAL_LINES.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8" - ); - await writeFile( - join(runsDir, `${SILENT_RUN_ID}__${SILENT_VENDOR_ID}.jsonl`), - `${SILENT_JOURNAL_LINES.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8" - ); - await writeFile( - join(runsDir, `${AMBIGUOUS_RUN_ID}__${AMBIGUOUS_VENDOR_ID}.jsonl`), - `${AMBIGUOUS_JOURNAL_LINES.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8" - ); - const sinkDir = join(env.fakeHome, ".config", "aidd", "telemetry"); - await mkdir(sinkDir, { recursive: true }); - await writeFile( - join(sinkDir, "2026-02-28.jsonl"), - `${RECORDS.map((r) => JSON.stringify(r)).join("\n")}\n`, - "utf-8" - ); - return env; - } - - // Each seed record carries its own cost, so a task row is found by the one figure that - // stays unique to the record that produced it - `requests` is 1 on every row here. - function taskRowOfCost(envelope: Envelope, costUsd: number): TaskRow | undefined { - return envelope.by_task.find((row) => row.totals.cost_micro_usd === costUsd * 1_000_000); - } - - it("attributes what follows a declaration while the session is still running, and the same closing the turn afterwards does not change", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout) as Envelope; - - // DURING_ALPHA_NO_TURN_END: after the declaration, before the write, no turn_end - // anywhere in this session's journal. - const alphaRow = taskRowOfCost(envelope, 2); - expect(alphaRow?.task).toBe(ALPHA_TASK); - - // Appending a turn_end well after the write must not change what is already attributed. - const runsDir = join(projectDir, "aidd_docs", "runs"); - const closedLines = [...ALPHA_JOURNAL_LINES, { type: "turn_end", at: "2026-02-10T09:42:00Z" }]; - await writeFile( - join(runsDir, `${RUN_ID}__${ALPHA_VENDOR_ID}.jsonl`), - `${closedLines.map((line) => JSON.stringify(line)).join("\n")}\n`, - "utf-8" - ); - const afterClose = JSON.parse( - (await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome)).stdout - ) as Envelope; - - expect(taskRowOfCost(afterClose, 2)?.task).toBe(ALPHA_TASK); - }); - - // The 09:05 record precedes its session's declaration, but that session wrote into exactly - // one task folder, so the written-file route names it, marked `inferred`. - it("names each unattributed reason distinctly, never collapsing two into one", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); - expect(result.exitCode).toBe(0); - const envelope = JSON.parse(result.stdout) as Envelope; - - expect(taskRowOfCost(envelope, 1)?.attribution).toBe("inferred"); - expect(taskRowOfCost(envelope, 1)?.task).toBe(ALPHA_TASK); - expect(taskRowOfCost(envelope, 16)?.reason).toBe("precedes-declaration"); - expect(taskRowOfCost(envelope, 4)?.reason).toBe("journal-silent"); - expect(taskRowOfCost(envelope, 8)?.reason).toBe("no-declaration"); - - const reasonRows = envelope.by_task.filter((row) => row.reason !== undefined); - expect(new Set(reasonRows.map((row) => row.reason)).size).toBe(reasonRows.length); - }); - - it("prints each reason in the text rendering too, never one label for all of them", async () => { - const { projectDir, fakeHome } = await seed(); - - const result = await runCli(["telemetry", "report", ...PERIOD], projectDir, fakeHome); - - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("before the next task this session declares"); - expect(result.stdout).toContain("the journal falls silent before this record"); - expect(result.stdout).toContain("no usable task declaration in this session"); - }); -}); diff --git a/cli/tests/e2e/telemetry.e2e.test.ts b/cli/tests/e2e/telemetry.e2e.test.ts deleted file mode 100644 index 5e807ef76..000000000 --- a/cli/tests/e2e/telemetry.e2e.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { createTestEnv, gitInit, runCli } from "./helpers.js"; - -const AIDD_DIR = ".aidd"; -const SWITCH_PATH = join(AIDD_DIR, "config.json"); -const LOCAL_SETTINGS_PATH = join(".claude", "settings.local.json"); -const PROJECT_SETTINGS_PATH = join(".claude", "settings.json"); - -async function seedManifest(projectDir: string): Promise { - await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); - await writeFile( - join(projectDir, AIDD_DIR, "manifest.json"), - JSON.stringify({ version: 8, tools: {}, marketplaces: {} }), - "utf-8" - ); -} - -async function installClaude(projectDir: string, fakeHome: string): Promise { - const result = await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); - expect(result.exitCode).toBe(0); -} - -describe.concurrent("E2E: aidd telemetry on/off — the switch alone", () => { - it("on succeeds with no endpoint anywhere, and writes no tool's settings file", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("telemetry-on-no-endpoint"); - try { - await gitInit(projectDir); - await seedManifest(projectDir); - await installClaude(projectDir, fakeHome); - - const on = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); - - expect(on.exitCode, on.stderr).toBe(0); - expect(existsSync(join(projectDir, SWITCH_PATH))).toBe(true); - expect(existsSync(join(projectDir, LOCAL_SETTINGS_PATH))).toBe(false); - const switchFile = JSON.parse(await readFile(join(projectDir, SWITCH_PATH), "utf-8")); - expect(switchFile.telemetry).toEqual({ enabled: true }); - } finally { - await cleanup(); - } - }); - - it("off on a project never turned on leaves the switch absent and every tool untouched", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("telemetry-switch-off"); - try { - await gitInit(projectDir); - await seedManifest(projectDir); - await installClaude(projectDir, fakeHome); - const cursorInstall = await runCli( - ["framework", "install", "--tool", "cursor"], - projectDir, - fakeHome - ); - expect(cursorInstall.exitCode).toBe(0); - - // Never turned on: `telemetry off` must leave every tool's config untouched. - const off = await runCli(["telemetry", "off"], projectDir, fakeHome); - expect(off.exitCode).toBe(0); - expect(off.stdout).toContain("already off"); - - expect(existsSync(join(projectDir, SWITCH_PATH))).toBe(false); - expect(existsSync(join(projectDir, LOCAL_SETTINGS_PATH))).toBe(false); - const projectSettings = await readFile(join(projectDir, PROJECT_SETTINGS_PATH), "utf-8"); - expect(projectSettings).not.toContain("CLAUDE_CODE_ENABLE_TELEMETRY"); - } finally { - await cleanup(); - } - }); -}); - -describe.concurrent("E2E: the deleted export route's commands are gone, not disabled", () => { - it.each(["receive", "endpoint"])( - "`telemetry %s` is refused as unknown, the way any unknown command is", - async (removed) => { - const { projectDir, fakeHome, cleanup } = await createTestEnv(`telemetry-removed-${removed}`); - try { - await gitInit(projectDir); - - const result = await runCli(["telemetry", removed], projectDir, fakeHome); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain(`unknown command '${removed}'`); - } finally { - await cleanup(); - } - } - ); -}); diff --git a/cli/tests/fixtures/cli-owns-read/expected-envelope.json b/cli/tests/fixtures/cli-owns-read/expected-envelope.json deleted file mode 100644 index 00d396af9..000000000 --- a/cli/tests/fixtures/cli-owns-read/expected-envelope.json +++ /dev/null @@ -1,544 +0,0 @@ -{ - "cost_report_version": 15, - "period": { - "from_day": "2026-01-01", - "to_day": "2026-01-31" - }, - "measurement_enabled": false, - "sessions": 4, - "totals": { - "requests": 4, - "input_tokens": 935, - "output_tokens": 590, - "cache_read_tokens": 14100, - "cache_creation_tokens": 1000 - }, - "by_step": [ - { - "step": "demo:02-beta", - "attribution": "tool-stated", - "totals": { - "requests": 1, - "input_tokens": 20, - "output_tokens": 300, - "cache_read_tokens": 7000, - "cache_creation_tokens": 0 - } - }, - { - "step": "demo:01-alpha", - "attribution": "tool-stated", - "totals": { - "requests": 1, - "input_tokens": 10, - "output_tokens": 200, - "cache_read_tokens": 5000, - "cache_creation_tokens": 1000 - } - }, - { - "step": "demo:01-alpha", - "attribution": "journal-interval", - "totals": { - "requests": 1, - "input_tokens": 900, - "output_tokens": 40, - "cache_read_tokens": 2000, - "cache_creation_tokens": 0 - } - }, - { - "attribution": "unattributed", - "totals": { - "requests": 1, - "input_tokens": 5, - "output_tokens": 50, - "cache_read_tokens": 100, - "cache_creation_tokens": 0 - } - } - ], - "by_flow": [ - { - "attribution": "unattributed", - "totals": { - "requests": 4, - "input_tokens": 935, - "output_tokens": 590, - "cache_read_tokens": 14100, - "cache_creation_tokens": 1000 - } - } - ], - "by_agent": [ - { - "attribution": "main-thread", - "totals": { - "requests": 3, - "input_tokens": 35, - "output_tokens": 550, - "cache_read_tokens": 12100, - "cache_creation_tokens": 1000 - } - }, - { - "attribution": "not-stated", - "totals": { - "requests": 1, - "input_tokens": 900, - "output_tokens": 40, - "cache_read_tokens": 2000, - "cache_creation_tokens": 0 - } - } - ], - "by_prompt": [ - { - "totals": { - "requests": 4, - "input_tokens": 935, - "output_tokens": 590, - "cache_read_tokens": 14100, - "cache_creation_tokens": 1000 - } - } - ], - "by_model": [ - { - "model": "claude-opus-5", - "totals": { - "requests": 3, - "input_tokens": 35, - "output_tokens": 550, - "cache_read_tokens": 12100, - "cache_creation_tokens": 1000 - } - }, - { - "model": "gpt-5.4", - "totals": { - "requests": 1, - "input_tokens": 900, - "output_tokens": 40, - "cache_read_tokens": 2000, - "cache_creation_tokens": 0 - } - } - ], - "by_tool": [ - { - "tool": "claude", - "coverage": "covered", - "capability": { - "local_read": { - "token_counters": true, - "amount": false, - "tool_stated_step": true, - "agent_name": true - }, - "export": null, - "journal_attributable": true, - "task_attributable": true - }, - "totals": { - "requests": 3, - "input_tokens": 35, - "output_tokens": 550, - "cache_read_tokens": 12100, - "cache_creation_tokens": 1000 - } - }, - { - "tool": "cursor", - "coverage": "not-covered", - "reason": "It writes no token count in any file it produces.", - "capability": { - "local_read": null, - "export": null, - "journal_attributable": true, - "task_attributable": true - }, - "totals": { - "requests": 0 - } - }, - { - "tool": "copilot", - "coverage": "covered", - "reason": "Its own file names outputTokens per turn, but session.shutdown carries all four counters for the whole session \u2014 a session total, never a sum of requests. Its four counters are measured disjoint, cached prompt included.", - "capability": { - "local_read": { - "token_counters": true, - "amount": false, - "tool_stated_step": false, - "agent_name": false - }, - "export": null, - "journal_attributable": true, - "task_attributable": true - }, - "totals": { - "requests": 0 - }, - "session_totals": { - "requests": 0, - "input_tokens": 16, - "output_tokens": 197, - "cache_read_tokens": 1900, - "cache_creation_tokens": 1000 - } - }, - { - "tool": "opencode", - "coverage": "covered", - "reason": "Its four counters are measured disjoint for the anthropic provider and for one OpenAI-compatible provider whose cache was exercised \u2014 not confirmed for a provider that reports prompt tokens inclusive of the cached ones, which none captured here does.", - "capability": { - "local_read": { - "token_counters": true, - "amount": false, - "tool_stated_step": false, - "agent_name": false - }, - "export": null, - "journal_attributable": true, - "task_attributable": true - }, - "totals": { - "requests": 0 - } - }, - { - "tool": "kilo", - "coverage": "not-covered", - "reason": "Kilo OpenTelemetry is experimental and not yet supported by AIDD.", - "capability": { - "local_read": null, - "export": null, - "journal_attributable": false, - "task_attributable": false - }, - "totals": { - "requests": 0 - } - }, - { - "tool": "codex", - "coverage": "covered", - "capability": { - "local_read": { - "token_counters": true, - "amount": false, - "tool_stated_step": false, - "agent_name": false - }, - "export": null, - "journal_attributable": true, - "task_attributable": true - }, - "totals": { - "requests": 1, - "input_tokens": 900, - "output_tokens": 40, - "cache_read_tokens": 2000, - "cache_creation_tokens": 0 - } - } - ], - "by_project": [ - { - "totals": { - "requests": 3, - "input_tokens": 35, - "output_tokens": 550, - "cache_read_tokens": 12100, - "cache_creation_tokens": 1000 - } - }, - { - "project": "demo-project", - "totals": { - "requests": 1, - "input_tokens": 900, - "output_tokens": 40, - "cache_read_tokens": 2000, - "cache_creation_tokens": 0 - } - } - ], - "by_task": [ - { - "reason": "no-journal", - "totals": { - "requests": 4, - "input_tokens": 935, - "output_tokens": 590, - "cache_read_tokens": 14100, - "cache_creation_tokens": 1000 - } - } - ], - "by_day": [ - { - "day": "2026-01-01", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-02", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-03", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-04", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-05", - "totals": { - "requests": 2, - "input_tokens": 30, - "output_tokens": 500, - "cache_read_tokens": 12000, - "cache_creation_tokens": 1000 - } - }, - { - "day": "2026-01-06", - "totals": { - "requests": 2, - "input_tokens": 905, - "output_tokens": 90, - "cache_read_tokens": 2100, - "cache_creation_tokens": 0 - } - }, - { - "day": "2026-01-07", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-08", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-09", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-10", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-11", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-12", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-13", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-14", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-15", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-16", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-17", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-18", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-19", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-20", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-21", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-22", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-23", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-24", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-25", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-26", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-27", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-28", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-29", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-30", - "totals": { - "requests": 0 - } - }, - { - "day": "2026-01-31", - "totals": { - "requests": 0 - } - } - ], - "by_person": [ - { - "resolution": "none", - "identities": [], - "totals": { - "requests": 4, - "input_tokens": 935, - "output_tokens": 590, - "cache_read_tokens": 14100, - "cache_creation_tokens": 1000 - } - } - ], - "attribution": [ - { - "attribution": "tool-stated", - "totals": { - "requests": 2, - "input_tokens": 30, - "output_tokens": 500, - "cache_read_tokens": 12000, - "cache_creation_tokens": 1000 - } - }, - { - "attribution": "prompt-matched", - "totals": { - "requests": 0 - } - }, - { - "attribution": "journal-interval", - "totals": { - "requests": 1, - "input_tokens": 900, - "output_tokens": 40, - "cache_read_tokens": 2000, - "cache_creation_tokens": 0 - } - }, - { - "attribution": "unattributed", - "totals": { - "requests": 1, - "input_tokens": 5, - "output_tokens": 50, - "cache_read_tokens": 100, - "cache_creation_tokens": 0 - } - } - ], - "read": { - "undated_records": 0, - "unreadable_lines": 0, - "identity_unusable": "absent" - }, - "by_backlog": [ - { - "reason": "no-journal", - "totals": { - "requests": 4, - "input_tokens": 935, - "output_tokens": 590, - "cache_read_tokens": 14100, - "cache_creation_tokens": 1000 - } - } - ] -} diff --git a/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-05.jsonl b/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-05.jsonl deleted file mode 100644 index 098e3b6c5..000000000 --- a/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-05.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"sink_schema_version": 2, "provenance": "local-read", "kind": "request", "tool": "claude", "vendor_id": "s-claude-1", "vendor_field": "sessionId", "turn_id": "req-1", "turn_field": "requestId", "model": "claude-opus-5", "effort": "high", "event_timestamp": "2026-01-05T09:00:00.000Z", "step": "demo:01-alpha", "step_plugin": "demo", "step_attribution": "tool-stated", "input_tokens": 10, "output_tokens": 200, "cache_read_tokens": 5000, "cache_creation_tokens": 1000} -{"sink_schema_version": 2, "provenance": "local-read", "kind": "request", "tool": "claude", "vendor_id": "s-claude-1", "vendor_field": "sessionId", "turn_id": "req-2", "turn_field": "requestId", "model": "claude-opus-5", "effort": "high", "event_timestamp": "2026-01-05T09:05:00.000Z", "step": "demo:02-beta", "step_plugin": "demo", "step_attribution": "tool-stated", "input_tokens": 20, "output_tokens": 300, "cache_read_tokens": 7000, "cache_creation_tokens": 0} diff --git a/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-06.jsonl b/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-06.jsonl deleted file mode 100644 index 920454bd2..000000000 --- a/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-06.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"sink_schema_version": 2, "provenance": "local-read", "kind": "request", "tool": "claude", "vendor_id": "s-claude-2", "vendor_field": "sessionId", "turn_id": "req-3", "turn_field": "requestId", "model": "claude-opus-5", "event_timestamp": "2026-01-06T11:00:00.000Z", "step_attribution": "unattributed", "input_tokens": 5, "output_tokens": 50, "cache_read_tokens": 100, "cache_creation_tokens": 0} -{"sink_schema_version": 2, "provenance": "local-read", "kind": "request", "tool": "codex", "vendor_id": "s-codex-1", "vendor_field": "session_meta.id", "turn_id": "turn-1", "turn_field": "turn_id", "model": "gpt-5.4", "effort": "high", "event_timestamp": "2026-01-06T14:00:00.000Z", "project_id": "demo-project", "project_field": "project_id", "step": "demo:01-alpha", "step_attribution": "journal-interval", "input_tokens": 900, "output_tokens": 40, "cache_read_tokens": 2000, "cache_creation_tokens": 0} diff --git a/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-07.jsonl b/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-07.jsonl deleted file mode 100644 index dbb2d2321..000000000 --- a/cli/tests/fixtures/cli-owns-read/telemetry/2026-01-07.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"sink_schema_version": 2, "provenance": "local-read", "kind": "session", "tool": "copilot", "vendor_id": "s-copilot-1", "vendor_field": "sessionId", "turn_id": "sess-1", "turn_field": "id", "event_timestamp": "2026-01-07T08:00:00.000Z", "step_attribution": "unattributed", "input_tokens": 16, "output_tokens": 197, "cache_read_tokens": 1900, "cache_creation_tokens": 1000} diff --git a/cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222.jsonl b/cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222.jsonl deleted file mode 100644 index 0617ed3ba..000000000 --- a/cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"type": "queue-operation", "operation": "enqueue", "timestamp": "2026-08-05T19:07:06.613Z", "sessionId": "22222222-2222-4222-8222-222222222222", "content": "[REDACTED]"} -{"parentUuid": "5ebfe527-e40c-40d5-be21-f77557f4d25f", "isSidechain": false, "promptId": "b1338100-0a1c-4870-99c9-38b239a7307d", "type": "user", "message": {"role": "user", "content": "[REDACTED]"}, "uuid": "6ff2c9ee-6f2f-4b0e-9b3a-2f6f6a4c7a01", "timestamp": "2026-08-05T19:07:06.700Z", "sessionId": "22222222-2222-4222-8222-222222222222"} -{"parentUuid": "ab09c36b-8097-486e-9b82-e541ccad78e6", "isSidechain": false, "message": {"model": "claude-sonnet-5", "id": "msg_011Cdk8FdSnWWvnuhkrHt9fU", "type": "message", "role": "assistant", "content": "[REDACTED]", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 18705, "cache_read_input_tokens": 24436, "output_tokens": 184, "service_tier": "standard"}}, "requestId": "req_011Cdk8FcLJwNkFzLNRR8BpN", "type": "assistant", "uuid": "f986e8f4-38e1-408a-be54-4d3363e48dc5", "timestamp": "2026-08-05T19:07:12.838Z", "effort": "high", "sessionId": "22222222-2222-4222-8222-222222222222"} -{"parentUuid": "f986e8f4-38e1-408a-be54-4d3363e48dc5", "isSidechain": false, "message": {"model": "claude-sonnet-5", "id": "msg_011Cdk8FdSnWWvnuhkrHt9fU", "type": "message", "role": "assistant", "content": "[REDACTED]", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 18705, "cache_read_input_tokens": 24436, "output_tokens": 184, "service_tier": "standard"}}, "requestId": "req_011Cdk8FcLJwNkFzLNRR8BpN", "type": "assistant", "uuid": "6b1c7ae6-acbb-46f8-86bb-4b6b95a174ee", "timestamp": "2026-08-05T19:07:15.789Z", "effort": "high", "sessionId": "22222222-2222-4222-8222-222222222222"} -{"parentUuid": "6b1c7ae6-acbb-46f8-86bb-4b6b95a174ee", "isSidechain": false, "type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01Abc", "content": "[REDACTED]"}]}, "uuid": "1c9f3b2a-9e77-4f52-9c2f-8e6f7b3a9d10", "timestamp": "2026-08-05T19:07:16.100Z", "sessionId": "22222222-2222-4222-8222-222222222222"} -{"parentUuid": "1c9f3b2a-9e77-4f52-9c2f-8e6f7b3a9d10", "isSidechain": false, "message": {"model": "claude-sonnet-5", "id": "msg_011Cdk8GAKucdYdLHAXJU365", "type": "message", "role": "assistant", "content": "[REDACTED]", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 713, "cache_read_input_tokens": 43141, "output_tokens": 191, "service_tier": "standard"}}, "requestId": "req_011Cdk8GAKucdYdLHAXJU365", "type": "assistant", "uuid": "2a3b4c5d-6e7f-4081-92a3-b4c5d6e7f809", "timestamp": "2026-08-05T19:07:19.451Z", "effort": "high", "sessionId": "22222222-2222-4222-8222-222222222222"} -{"parentUuid": "2a3b4c5d-6e7f-4081-92a3-b4c5d6e7f809", "isSidechain": false, "type": "assistant", "uuid": "4c5d6e7f-8091-42a3-b4c5-d6e7f8091223", "timestamp": "2026-08-05T19:07:21.101Z", "message": {"model": "", "id": "2a432358-0a03-4540-b441-180b3587daad", "type": "message", "role": "assistant", "content": "[REDACTED]", "stop_reason": null, "usage": {"input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}}}, "requestId": "req_011CdhNELnGn9e99rkVfSSSc", "error": {}, "isApiErrorMessage": true, "sessionId": "22222222-2222-4222-8222-222222222222"} -{"parentUuid": "2a3b4c5d-6e7f-4081-92a3-b4c5d6e7f809", "isSidechain": false, "message": {"model": "claude-sonnet-5", "id": "msg_011Cdk8GZ2QZU7DF2sXbhWSc", "type": "message", "role": "assistant", "content": "[REDACTED]", "stop_reason": "end_turn", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 207, "cache_read_input_tokens": 43854, "output_tokens": 174, "service_tier": "standard"}}, "requestId": "req_011Cdk8GZ2QZU7DF2sXbhWSc", "type": "assistant", "uuid": "3b4c5d6e-7f80-4192-a3b4-c5d6e7f80912", "timestamp": "2026-08-05T19:07:24.829Z", "effort": "high", "sessionId": "22222222-2222-4222-8222-222222222222"} diff --git a/cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222/subagents/agent-aa81cdef3bb58820c.jsonl b/cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222/subagents/agent-aa81cdef3bb58820c.jsonl deleted file mode 100644 index 1522fa923..000000000 --- a/cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222/subagents/agent-aa81cdef3bb58820c.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"parentUuid": "e836d32c-cda5-4e30-8739-e228f449abf5", "isSidechain": true, "agentId": "aa81cdef3bb58820c", "requestId": "req_011Ce2HDaNo7CVCZKrT8yryX", "attributionAgent": "Explore", "attributionSkill": "probe-echo", "type": "assistant", "uuid": "65bac71a-2f87-4be4-95b8-dc1297d50e25", "timestamp": "2026-08-14T07:54:15.988Z", "effort": "high", "sessionId": "22222222-2222-4222-8222-222222222222", "message": {"model": "claude-opus-5", "id": "msg_011Ce2HDsubagent0001", "type": "message", "role": "assistant", "content": "[REDACTED]", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 20212, "cache_read_input_tokens": 0, "output_tokens": 1, "service_tier": "standard"}}} diff --git a/cli/tests/fixtures/local-cost/.codex/sessions/2026/07/16/rollout-2026-07-16T09-25-07-019f69d0-9e1f-7951-86c9-ddb23cfd51f4.jsonl b/cli/tests/fixtures/local-cost/.codex/sessions/2026/07/16/rollout-2026-07-16T09-25-07-019f69d0-9e1f-7951-86c9-ddb23cfd51f4.jsonl deleted file mode 100644 index 95ab9e326..000000000 --- a/cli/tests/fixtures/local-cost/.codex/sessions/2026/07/16/rollout-2026-07-16T09-25-07-019f69d0-9e1f-7951-86c9-ddb23cfd51f4.jsonl +++ /dev/null @@ -1,4 +0,0 @@ -{"timestamp": "2026-07-16T07:26:07.000Z", "type": "session_meta", "payload": {"id": "019f69d0-9e1f-7951-86c9-ddb23cfd51f4", "session_id": "019f69d0-9e1f-7951-86c9-ddb23cfd51f4"}} -{"timestamp": "2026-07-16T07:26:08.898Z", "type": "turn_context", "payload": {"turn_id": "019f69d1-8dcc-7272-a9eb-523ef9976475", "model": "gpt-5.5", "effort": "high"}} -{"timestamp": "2026-07-16T07:26:24.410Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 20600, "cached_input_tokens": 1920, "output_tokens": 730, "reasoning_output_tokens": 345, "total_tokens": 21330}, "last_token_usage": {"input_tokens": 20600, "cached_input_tokens": 1920, "output_tokens": 730, "reasoning_output_tokens": 345, "total_tokens": 21330}, "model_context_window": 258400}}} -{"timestamp": "2026-07-16T07:26:43.262Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 26745, "cached_input_tokens": 20352, "output_tokens": 418, "reasoning_output_tokens": 40, "total_tokens": 27163}, "last_token_usage": {"input_tokens": 26745, "cached_input_tokens": 20352, "output_tokens": 418, "reasoning_output_tokens": 40, "total_tokens": 27163}, "model_context_window": 258400}}} diff --git a/cli/tests/fixtures/local-cost/.codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl b/cli/tests/fixtures/local-cost/.codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl deleted file mode 100644 index 42efb2307..000000000 --- a/cli/tests/fixtures/local-cost/.codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl +++ /dev/null @@ -1,8 +0,0 @@ -{"timestamp": "2026-07-29T15:12:26.269Z", "type": "session_meta", "payload": {"id": "019fae6f-2009-7cd3-86b2-b8f83481b160", "session_id": "019f69d0-9e1f-7951-86c9-ddb23cfd51f4"}} -{"timestamp": "2026-07-29T15:12:27.889Z", "type": "turn_context", "payload": {"turn_id": "019fae6f-2084-7d63-b3c1-3d45d0864fe9", "model": "gpt-5.6-sol", "effort": "high"}} -{"timestamp": "2026-07-29T15:12:33.300Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 22229, "cached_input_tokens": 20224, "cache_write_input_tokens": 0, "output_tokens": 231, "reasoning_output_tokens": 69, "total_tokens": 22460}, "last_token_usage": {"input_tokens": 22229, "cached_input_tokens": 20224, "cache_write_input_tokens": 0, "output_tokens": 231, "reasoning_output_tokens": 69, "total_tokens": 22460}, "model_context_window": 258400}}} -{"timestamp": "2026-07-29T15:12:40.597Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 24692, "cached_input_tokens": 21248, "cache_write_input_tokens": 0, "output_tokens": 206, "reasoning_output_tokens": 32, "total_tokens": 24898}, "last_token_usage": {"input_tokens": 24692, "cached_input_tokens": 21248, "cache_write_input_tokens": 0, "output_tokens": 206, "reasoning_output_tokens": 32, "total_tokens": 24898}, "model_context_window": 258400}}} -{"timestamp": "2026-07-29T15:12:48.955Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 27769, "cached_input_tokens": 24320, "cache_write_input_tokens": 0, "output_tokens": 390, "reasoning_output_tokens": 186, "total_tokens": 28159}, "last_token_usage": {"input_tokens": 27769, "cached_input_tokens": 24320, "cache_write_input_tokens": 0, "output_tokens": 390, "reasoning_output_tokens": 186, "total_tokens": 28159}, "model_context_window": 258400}}} -{"timestamp": "2026-07-29T15:15:13.692Z", "type": "turn_context", "payload": {"turn_id": "019fae71-ae8b-7850-a982-78d7cd9dba52", "model": "gpt-5.6-sol", "effort": "high"}} -{"timestamp": "2026-07-29T15:16:48.112Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 51712, "cached_input_tokens": 48896, "cache_write_input_tokens": 0, "output_tokens": 1401, "reasoning_output_tokens": 874, "total_tokens": 53113}, "last_token_usage": {"input_tokens": 51712, "cached_input_tokens": 48896, "cache_write_input_tokens": 0, "output_tokens": 1401, "reasoning_output_tokens": 874, "total_tokens": 53113}, "model_context_window": 258400}}} -{"timestamp": "2026-07-29T15:17:33.271Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 53160, "cached_input_tokens": 50944, "cache_write_input_tokens": 0, "output_tokens": 2149, "reasoning_output_tokens": 21, "total_tokens": 55309}, "last_token_usage": {"input_tokens": 53160, "cached_input_tokens": 50944, "cache_write_input_tokens": 0, "output_tokens": 2149, "reasoning_output_tokens": 21, "total_tokens": 55309}, "model_context_window": 258400}}} diff --git a/cli/tests/fixtures/local-cost/.copilot/session-state/33333333-3333-4333-8333-333333333333/events.jsonl b/cli/tests/fixtures/local-cost/.copilot/session-state/33333333-3333-4333-8333-333333333333/events.jsonl deleted file mode 100644 index 9a91d4a6a..000000000 --- a/cli/tests/fixtures/local-cost/.copilot/session-state/33333333-3333-4333-8333-333333333333/events.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"type":"session.start","data":{"sessionId":"33333333-3333-4333-8333-333333333333","producer":"copilot-agent","copilotVersion":"1.0.80","startTime":"2026-08-21T14:07:44.951Z","contextTier":null,"alreadyInUse":false,"remoteSteerable":false},"id":"6262bc5d-341b-4f0e-8507-78c48100721b","timestamp":"2026-08-21T14:07:44.991Z","parentId":null} -{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":0.33,"totalNanoAiu":2655750000,"tokenDetails":{"input":{"tokenCount":10},"cache_read":{"tokenCount":0},"cache_write":{"tokenCount":21070},"output":{"tokenCount":42}},"totalApiDurationMs":1878,"sessionStartTime":1787321264951,"eventsFileSizeBytes":49091,"codeChanges":{"linesAdded":0,"linesRemoved":0,"filesModified":[]},"modelMetrics":{"claude-haiku-4.5":{"requests":{"count":1,"cost":0.33},"usage":{"inputTokens":21080,"outputTokens":42,"cacheReadTokens":0,"cacheWriteTokens":21070,"reasoningTokens":34},"totalNanoAiu":2655750000,"tokenDetails":{"input":{"tokenCount":10},"cache_read":{"tokenCount":0},"cache_write":{"tokenCount":21070},"output":{"tokenCount":42}}}},"currentModel":"claude-haiku-4.5","currentTokens":17916,"systemTokens":9307,"conversationTokens":125,"toolDefinitionsTokens":8481},"id":"99ccf9e7-b3ac-4145-a622-31852ec698cb","timestamp":"2026-08-21T14:07:49.286Z","parentId":"35102a73-0ee1-4515-b017-17f76f0bfb35"} diff --git a/cli/tests/fixtures/local-cost/.copilot/session-state/44444444-4444-4444-8444-444444444444/events.jsonl b/cli/tests/fixtures/local-cost/.copilot/session-state/44444444-4444-4444-8444-444444444444/events.jsonl deleted file mode 100644 index 364836800..000000000 --- a/cli/tests/fixtures/local-cost/.copilot/session-state/44444444-4444-4444-8444-444444444444/events.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"type":"session.start","data":{"sessionId":"44444444-4444-4444-8444-444444444444","producer":"copilot-agent","copilotVersion":"1.0.80","startTime":"2026-08-14T08:12:35.120Z","contextTier":null,"alreadyInUse":false,"remoteSteerable":false},"id":"1db71248-bf68-4be4-9a8c-4a6bd607700e","timestamp":"2026-08-14T08:12:35.150Z","parentId":null} -{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":0,"totalNanoAiu":0,"totalApiDurationMs":0,"sessionStartTime":1786695155120,"codeChanges":{"linesAdded":0,"linesRemoved":0,"filesModified":[]},"modelMetrics":{},"currentModel":"claude-haiku-4.5","currentTokens":15687,"systemTokens":6022,"conversationTokens":81,"toolDefinitionsTokens":9581},"id":"587032dc-c1b4-4894-baba-d03159c21434","timestamp":"2026-08-14T08:12:40.685Z","parentId":"1db71248-bf68-4be4-9a8c-4a6bd607700e"} diff --git a/cli/tests/fixtures/local-cost/.copilot/session-state/55555555-5555-4555-8555-555555555555/events.jsonl b/cli/tests/fixtures/local-cost/.copilot/session-state/55555555-5555-4555-8555-555555555555/events.jsonl deleted file mode 100644 index 8fe3cec0d..000000000 --- a/cli/tests/fixtures/local-cost/.copilot/session-state/55555555-5555-4555-8555-555555555555/events.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"type":"session.start","data":{"sessionId":"55555555-5555-4555-8555-555555555555","producer":"copilot-agent","copilotVersion":"1.0.82","startTime":"2026-09-06T05:38:13.535Z","contextTier":null,"alreadyInUse":false,"remoteSteerable":false},"id":"11111111-1111-4111-8111-111111111111","timestamp":"2026-09-06T05:38:13.560Z","parentId":null} -{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":1,"totalNanoAiu":668316000,"tokenDetails":{"input":{"tokenCount":9},"cache_read":{"tokenCount":42038},"cache_write":{"tokenCount":21404},"output":{"tokenCount":408}},"totalApiDurationMs":8147,"sessionStartTime":1788673093535,"eventsFileSizeBytes":104412,"codeChanges":{"linesAdded":3,"linesRemoved":0,"filesModified":["docs/note.md"]},"modelMetrics":{"gpt-5.6-luna":{"requests":{"count":3,"cost":1},"usage":{"inputTokens":63451,"outputTokens":408,"cacheReadTokens":42038,"cacheWriteTokens":21404,"reasoningTokens":55},"totalNanoAiu":668316000,"tokenDetails":{"input":{"tokenCount":9},"cache_read":{"tokenCount":42038},"cache_write":{"tokenCount":21404},"output":{"tokenCount":408}}}},"currentModel":"gpt-5.6-luna","currentTokens":22282,"systemTokens":13260,"conversationTokens":691,"toolDefinitionsTokens":8328},"id":"22222222-2222-4222-8222-222222222222","timestamp":"2026-09-06T05:38:32.658Z","parentId":"11111111-1111-4111-8111-111111111111"} diff --git a/cli/tests/fixtures/telemetry-sink/expected.jsonl b/cli/tests/fixtures/telemetry-sink/expected.jsonl deleted file mode 100644 index cda9809c3..000000000 --- a/cli/tests/fixtures/telemetry-sink/expected.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"sink_schema_version":2,"kind":"request","provenance":"export","tool":"claude","vendor_id":"7c53f826-fc3e-4729-8e2b-2cba887d3926","vendor_field":"session.id","turn_id":"a4b7b0b6-dc16-4889-b25a-def1d207aec9","turn_field":"prompt.id","project_id":"acme/example-project","user_id":"user_example_hash_0000000000000000","cost_usd":0.0132201,"input_tokens":2,"output_tokens":4,"cache_read_tokens":43847,"cache_creation_tokens":0,"model":"claude-sonnet-5","effort":"high","speed":"normal","query_source":"sdk","duration_ms":1598,"event_timestamp":"2026-08-18T17:04:39.258Z"} -{"sink_schema_version":2,"kind":"session","provenance":"export","tool":"claude","vendor_id":"22177147-d8cb-4ee1-976f-0ef82bd62491","vendor_field":"session.id","user_id":"user_example_hash_0000000000000000","model":"claude-sonnet-5","query_source":"main","effort":"high","active_time_s":9.714} -{"sink_schema_version":2,"kind":"request","provenance":"local-read","tool":"codex","vendor_id":"conv-example-0000-4000-8000-000000000000","vendor_field":"conversation.id","cost_usd":0.021,"model":"gpt-5-codex"} diff --git a/cli/tests/fixtures/telemetry-sink/opencode-export-non-anthropic-cache.json b/cli/tests/fixtures/telemetry-sink/opencode-export-non-anthropic-cache.json deleted file mode 100644 index 39e79e5c9..000000000 --- a/cli/tests/fixtures/telemetry-sink/opencode-export-non-anthropic-cache.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "info": { - "id": "ses_test_non_anthropic", - "slug": "quick-circuit", - "projectID": "0000000000000000000000000000000000000000", - "directory": "[redacted:session-directory]", - "title": "[redacted:session-title]", - "version": "1.14.20", - "permission": [ - { - "permission": "question", - "pattern": "*", - "action": "deny" - }, - { - "permission": "plan_enter", - "pattern": "*", - "action": "deny" - }, - { - "permission": "plan_exit", - "pattern": "*", - "action": "deny" - } - ], - "time": { - "created": 1788672555178, - "updated": 1788672567780 - } - }, - "messages": [ - { - "info": { - "role": "user", - "time": { - "created": 1788672555378 - }, - "agent": "build", - "model": { - "providerID": "opencode", - "modelID": "ling-3.0-flash-fin-free" - }, - "summary": { - "diffs": [ - { - "file": "[redacted:message-diff-file:0]", - "patch": "[redacted:message-diff-patch:0]", - "additions": 1, - "deletions": 0, - "status": "added" - } - ] - }, - "id": "msg_075313972001oFmFIrMP8QKaMI", - "sessionID": "ses_test_non_anthropic" - } - }, - { - "info": { - "parentID": "msg_075313972001oFmFIrMP8QKaMI", - "role": "assistant", - "mode": "build", - "agent": "build", - "path": { - "cwd": "[redacted:cwd:msg_07531399f001zRIlCAkqvcdz38]", - "root": "[redacted:root:msg_07531399f001zRIlCAkqvcdz38]" - }, - "cost": 0, - "tokens": { - "total": 29089, - "input": 28242, - "output": 193, - "reasoning": 14, - "cache": { - "write": 0, - "read": 640 - } - }, - "modelID": "ling-3.0-flash-fin-free", - "providerID": "opencode", - "time": { - "created": 1788672555423, - "completed": 1788672565911 - }, - "finish": "tool-calls", - "id": "msg_07531399f001zRIlCAkqvcdz38", - "sessionID": "ses_test_non_anthropic" - } - }, - { - "info": { - "parentID": "msg_075313972001oFmFIrMP8QKaMI", - "role": "assistant", - "mode": "build", - "agent": "build", - "path": { - "cwd": "[redacted:cwd:msg_075316299001VsJLAfQn01vDdX]", - "root": "[redacted:root:msg_075316299001VsJLAfQn01vDdX]" - }, - "cost": 0, - "tokens": { - "total": 29356, - "input": 269, - "output": 137, - "reasoning": 22, - "cache": { - "write": 0, - "read": 28928 - } - }, - "modelID": "ling-3.0-flash-fin-free", - "providerID": "opencode", - "time": { - "created": 1788672565913, - "completed": 1788672567732 - }, - "finish": "tool-calls", - "id": "msg_075316299001VsJLAfQn01vDdX", - "sessionID": "ses_test_non_anthropic" - } - }, - { - "info": { - "parentID": "msg_075313972001oFmFIrMP8QKaMI", - "role": "assistant", - "mode": "build", - "agent": "build", - "path": { - "cwd": "[redacted:cwd:msg_0753169b5001dX3zYvM2u0UyHn]", - "root": "[redacted:root:msg_0753169b5001dX3zYvM2u0UyHn]" - }, - "cost": 0, - "tokens": { - "total": 29438, - "input": 196, - "output": 56, - "reasoning": 2, - "cache": { - "write": 0, - "read": 29184 - } - }, - "modelID": "ling-3.0-flash-fin-free", - "providerID": "opencode", - "time": { - "created": 1788672567733, - "completed": 1788672569281 - }, - "finish": "stop", - "id": "msg_0753169b5001dX3zYvM2u0UyHn", - "sessionID": "ses_test_non_anthropic" - } - } - ] -} diff --git a/cli/tests/fixtures/telemetry-sink/opencode-export.json b/cli/tests/fixtures/telemetry-sink/opencode-export.json deleted file mode 100644 index c4133ace8..000000000 --- a/cli/tests/fixtures/telemetry-sink/opencode-export.json +++ /dev/null @@ -1,224 +0,0 @@ -{ - "info": { - "id": "ses_30aea4e57ffeELyWQNJcO9DHET", - "slug": "curious-comet", - "projectID": "24f278258dfd48bb1176986ff46e47369080c9a1", - "directory": "[redacted:session-directory:ses_30aea4e57ffeELyWQNJcO9DHET]", - "title": "[redacted:session-title:ses_30aea4e57ffeELyWQNJcO9DHET]", - "version": "1.2.24", - "summary": { - "additions": 0, - "deletions": 0, - "files": 0 - }, - "time": { - "created": 1773638365608, - "updated": 1773639419671 - } - }, - "messages": [ - { - "info": { - "role": "user", - "time": { - "created": 1773638365612 - }, - "summary": { - "diffs": [] - }, - "agent": "build", - "model": { - "providerID": "anthropic", - "modelID": "claude-sonnet-4-6" - }, - "id": "msg_cf515b1aa0013pyPAIQe6EtEp1", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - }, - { - "info": { - "role": "assistant", - "time": { - "created": 1773638365618, - "completed": 1773638370433 - }, - "parentID": "msg_cf515b1aa0013pyPAIQe6EtEp1", - "modelID": "claude-sonnet-4-6", - "providerID": "anthropic", - "mode": "build", - "agent": "build", - "path": { - "cwd": "[redacted:cwd:msg_cf515b1b20011NzmARPrSpI1lW]", - "root": "[redacted:root:msg_cf515b1b20011NzmARPrSpI1lW]" - }, - "cost": 0, - "tokens": { - "total": 46898, - "input": 3, - "output": 115, - "reasoning": 0, - "cache": { - "read": 43639, - "write": 3141 - } - }, - "finish": "tool-calls", - "id": "msg_cf515b1b20011NzmARPrSpI1lW", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - }, - { - "info": { - "role": "assistant", - "time": { - "created": 1773638370434, - "completed": 1773638375000 - }, - "parentID": "msg_cf515b1aa0013pyPAIQe6EtEp1", - "modelID": "claude-sonnet-4-6", - "providerID": "anthropic", - "mode": "build", - "agent": "build", - "path": { - "cwd": "[redacted:cwd:msg_cf515c482001XcMRpKRNVBj0v9]", - "root": "[redacted:root:msg_cf515c482001XcMRpKRNVBj0v9]" - }, - "cost": 0, - "tokens": { - "total": 47195, - "input": 1, - "output": 238, - "reasoning": 0, - "cache": { - "read": 46780, - "write": 176 - } - }, - "finish": "tool-calls", - "id": "msg_cf515c482001XcMRpKRNVBj0v9", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - }, - { - "info": { - "role": "assistant", - "time": { - "created": 1773638375001, - "completed": 1773638379047 - }, - "parentID": "msg_cf515b1aa0013pyPAIQe6EtEp1", - "modelID": "claude-sonnet-4-6", - "providerID": "anthropic", - "mode": "build", - "agent": "build", - "path": { - "cwd": "[redacted:cwd:msg_cf515d659001v8AyNXNm4y69T8]", - "root": "[redacted:root:msg_cf515d659001v8AyNXNm4y69T8]" - }, - "cost": 0, - "tokens": { - "total": 51192, - "input": 1, - "output": 161, - "reasoning": 0, - "cache": { - "read": 46956, - "write": 4074 - } - }, - "finish": "tool-calls", - "id": "msg_cf515d659001v8AyNXNm4y69T8", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - }, - { - "info": { - "role": "assistant", - "time": { - "created": 1773638379047 - }, - "parentID": "msg_cf515b1aa0013pyPAIQe6EtEp1", - "modelID": "claude-sonnet-4-6", - "providerID": "anthropic", - "mode": "build", - "agent": "build", - "path": { - "cwd": "[redacted:cwd:msg_cf515e6270019kLPJWNgcnoVSu]", - "root": "[redacted:root:msg_cf515e6270019kLPJWNgcnoVSu]" - }, - "cost": 0, - "tokens": { - "input": 0, - "output": 0, - "reasoning": 0, - "cache": { - "read": 0, - "write": 0 - } - }, - "id": "msg_cf515e6270019kLPJWNgcnoVSu", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - }, - { - "info": { - "role": "user", - "time": { - "created": 1773638396064 - }, - "agent": "build", - "model": { - "providerID": "anthropic", - "modelID": "claude-sonnet-4-6" - }, - "id": "msg_cf516289a0010Tfo9ztP07AiEk", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - }, - { - "info": { - "role": "user", - "time": { - "created": 1773638416754 - }, - "agent": "build", - "model": { - "providerID": "anthropic", - "modelID": "claude-sonnet-4-6" - }, - "id": "msg_cf5167969001dEOWThG2jjVfVF", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - }, - { - "info": { - "role": "user", - "time": { - "created": 1773639416370 - }, - "agent": "build", - "model": { - "providerID": "anthropic", - "modelID": "claude-sonnet-4-6" - }, - "id": "msg_cf525ba290013xeSu47Hp4a2mf", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - }, - { - "info": { - "role": "user", - "time": { - "created": 1773639419670 - }, - "agent": "build", - "model": { - "providerID": "anthropic", - "modelID": "claude-sonnet-4-6" - }, - "id": "msg_cf525c70e001xnSjNAacx1ba4K", - "sessionID": "ses_30aea4e57ffeELyWQNJcO9DHET" - } - } - ] -} diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index 7add7ffbf..e4c771321 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -1748,7 +1748,7 @@ ".opencode/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".opencode/plugin/aidd-context-hooks.js": "f8f9e775dd223c0385b330f3833e26343f5d19975a06cdec9ae981977d140fed", + ".opencode/plugin/aidd-context-hooks.js": "4d4de407a2afae13045eaf8291efac247717f9fdbe7d983be9f977eb947e4b6e", ".opencode/hooks/opencode-events.js": "ce9363e18f1f5b11c401a30bd1d8643dcdd72ced0182d97baae763276216babf", ".opencode/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".opencode/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index 4d8f88536..24f1433ec 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -2,7 +2,7 @@ { "invocation": "aidd", "exitCode": 0, - "help": "Usage: aidd [options] [command]\n\nGenerate AI coding assistant configurations from the AIDD framework\n\nOptions:\n -V, --version Show version number\n --verbose Show detailed diagnostic output (default: false)\n -h, --help display help for command\n\nCommands:\n setup [options] Set up or update the project to a correct state\n — bootstraps the whole project (marketplace,\n framework, tools, plugins); see `framework\n install`, which acts on the framework alone\n framework Manage the framework's lifecycle on installed\n tools\n translate [options] Convert an arbitrary source into a target-native\n plugin tree — records nothing (see `sync` for\n the manifest-driven, tracked version)\n plugin Manage plugins for AI tools\n marketplace Manage plugin marketplaces\n auth Manage authentication\n sync [options] [files...] Rewrite owned files from what is already there —\n regenerate tracked files, driven by the manifest\n (see `translate`, which converts a source\n without recording anything)\n update|upgrade [options] Update the aidd CLI itself to the latest version\n doctor [options] Detected and equipped tools, plugins, drift, and\n problems — across all tools or one\n clean [options] Remove all AIDD-managed files from the project —\n retires every part of AIDD; see `framework\n remove`, which removes the framework only\n telemetry Control whether AIDD may measure this project\n help [command] display help for command" + "help": "Usage: aidd [options] [command]\n\nGenerate AI coding assistant configurations from the AIDD framework\n\nOptions:\n -V, --version Show version number\n --verbose Show detailed diagnostic output (default: false)\n -h, --help display help for command\n\nCommands:\n setup [options] Set up or update the project to a correct state\n — bootstraps the whole project (marketplace,\n framework, tools, plugins); see `framework\n install`, which acts on the framework alone\n framework Manage the framework's lifecycle on installed\n tools\n translate [options] Convert an arbitrary source into a target-native\n plugin tree — records nothing (see `sync` for\n the manifest-driven, tracked version)\n plugin Manage plugins for AI tools\n marketplace Manage plugin marketplaces\n auth Manage authentication\n sync [options] [files...] Rewrite owned files from what is already there —\n regenerate tracked files, driven by the manifest\n (see `translate`, which converts a source\n without recording anything)\n update|upgrade [options] Update the aidd CLI itself to the latest version\n doctor [options] Detected and equipped tools, plugins, drift, and\n problems — across all tools or one\n clean [options] Remove all AIDD-managed files from the project —\n retires every part of AIDD; see `framework\n remove`, which removes the framework only\n help [command] display help for command" }, { "invocation": "aidd auth", @@ -129,66 +129,6 @@ "exitCode": 0, "help": "Usage: aidd sync [options] [files...]\n\nRewrite owned files from what is already there — regenerate tracked files,\ndriven by the manifest (see `translate`, which converts a source without\nrecording anything)\n\nArguments:\n files Limit sync to specific tracked files\n\nOptions:\n -f, --force Sync without prompting (default: false)\n --tool Limit sync to a specific tool\n --plugin Limit sync to a specific plugin\n --scope project (default) resolves this project's own manifest; user\n resolves the machine-wide manifest --scope user setup wrote,\n restoring no project files\n -h, --help display help for command" }, - { - "invocation": "aidd telemetry", - "exitCode": 0, - "help": "Usage: aidd telemetry [options] [command]\n\nControl whether AIDD may measure this project\n\nOptions:\n -h, --help display help for command\n\nCommands:\n on [options] Turn on the AIDD telemetry switch and git-ignore the run\n journal\n read [options] Read what sessions cost from the files their tools already\n wrote, with no process running\n identity Whether this person's own identifier is attached to records\n read locally\n check Check whether the measurement chain is actually recording\n for this project\n report [options] Report what a period, or one task inside it, cost — tokens,\n models and steps, with how strongly each was attributed\n off Turn off the AIDD telemetry switch, warning if a tool's own\n settings file still exports\n forget [options] Irreversibly remove what this tool measured: this project's\n run journal, this machine's stored records, and this\n machine's identity file\n help [command] display help for command" - }, - { - "invocation": "aidd telemetry check", - "exitCode": 0, - "help": "Usage: aidd telemetry check [options]\n\nCheck whether the measurement chain is actually recording for this project\n\nOptions:\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry forget", - "exitCode": 0, - "help": "Usage: aidd telemetry forget [options]\n\nIrreversibly remove what this tool measured: this project's run journal, this\nmachine's stored records, and this machine's identity file\n\nOptions:\n --yes Confirm removal after seeing what would go — without it, nothing\n is removed (default: false)\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry identity", - "exitCode": 0, - "help": "Usage: aidd telemetry identity [options] [command]\n\nWhether this person's own identifier is attached to records read locally\n\nOptions:\n -h, --help display help for command\n\nCommands:\n use [options] [identifier] Mint this person's identifier, or take one minted\n on another machine. --name attaches a display name\n off Opt out: new records carry no person, from now on\n link Add an identifier this person cannot choose onto\n this same person - one row, not two, in a report\n unlink Withdraw an added identifier from this person" - }, - { - "invocation": "aidd telemetry identity link", - "exitCode": 0, - "help": "Usage: aidd telemetry identity link [options] \n\nAdd an identifier this person cannot choose onto this same person - one row, not\ntwo, in a report\n\nOptions:\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry identity off", - "exitCode": 0, - "help": "Usage: aidd telemetry identity off [options]\n\nOpt out: new records carry no person, from now on\n\nOptions:\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry identity unlink", - "exitCode": 0, - "help": "Usage: aidd telemetry identity unlink [options] \n\nWithdraw an added identifier from this person\n\nOptions:\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry identity use", - "exitCode": 0, - "help": "Usage: aidd telemetry identity use [options] [identifier]\n\nMint this person's identifier, or take one minted on another machine. --name\nattaches a display name\n\nOptions:\n --name A display name for whichever identifier this call settles on\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry off", - "exitCode": 0, - "help": "Usage: aidd telemetry off [options]\n\nTurn off the AIDD telemetry switch, warning if a tool's own settings file still\nexports\n\nOptions:\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry on", - "exitCode": 0, - "help": "Usage: aidd telemetry on [options]\n\nTurn on the AIDD telemetry switch and git-ignore the run journal\n\nOptions:\n --yes Confirm writing the git-tracked switch — this turns measurement on\n for everyone who clones (default: false)\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry read", - "exitCode": 0, - "help": "Usage: aidd telemetry read [options]\n\nRead what sessions cost from the files their tools already wrote, with no\nprocess running\n\nOptions:\n --session One session to read. Omitted, every session the run journal\n knows is read\n -h, --help display help for command" - }, - { - "invocation": "aidd telemetry report", - "exitCode": 0, - "help": "Usage: aidd telemetry report [options]\n\nReport what a period, or one task inside it, cost — tokens, models and steps,\nwith how strongly each was attributed\n\nOptions:\n --from First UTC day to report, as YYYY-MM-DD\n --to Last UTC day to report, as YYYY-MM-DD (default today)\n --days How many days back to report, ending at --to (default 7)\n --task Restrict to the sessions that wrote into this task, as\n /\n --project Restrict to this project\n --step Restrict to this step\n --model Restrict to this model\n --tool Restrict to this tool\n --axis Print one axis as a table to paste elsewhere: total | day |\n step | model | agent | prompt | task | backlog | flow |\n tool | project | person\n --json Print one object a program can parse, instead of text for a\n person\n -h, --help display help for command" - }, { "invocation": "aidd translate", "exitCode": 0, diff --git a/cli/tests/helpers/ports/in-memory-file-adapter.ts b/cli/tests/helpers/ports/in-memory-file-adapter.ts index c9b8643d2..e41ebd7bd 100644 --- a/cli/tests/helpers/ports/in-memory-file-adapter.ts +++ b/cli/tests/helpers/ports/in-memory-file-adapter.ts @@ -147,10 +147,6 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { this.files.set(normalizedPath, JSON.stringify(merged, null, 2)); } - async chmodExecutable(_path: string): Promise { - // No-op: no permission bits in memory - } - async deleteDirectory(dirPath: string): Promise { const normalizedDir = norm(dirPath); const prefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; diff --git a/cli/tests/helpers/ports/in-memory-person-identity-reader.ts b/cli/tests/helpers/ports/in-memory-person-identity-reader.ts deleted file mode 100644 index c6695558f..000000000 --- a/cli/tests/helpers/ports/in-memory-person-identity-reader.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { - PersonIdentity, - PersonIdentityReader, -} from "../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; - -/** In-memory double for `PersonIdentityReader` — one identity, set once, or `null`. */ -export class InMemoryPersonIdentityReader implements PersonIdentityReader { - constructor(private identity: PersonIdentity | null = null) {} - - set(identity: PersonIdentity | null): void { - this.identity = identity; - } - - async read(): Promise { - return this.identity; - } -} - -/** Nobody opted in — the default a fresh installation reads, on every call. */ -export const NULL_PERSON_IDENTITY_READER: PersonIdentityReader = { - read: async () => null, -}; diff --git a/cli/tests/helpers/ports/in-memory-person-identity-store.ts b/cli/tests/helpers/ports/in-memory-person-identity-store.ts deleted file mode 100644 index c7e5366d2..000000000 --- a/cli/tests/helpers/ports/in-memory-person-identity-store.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - withAlsoMeAdded, - withAlsoMeRemoved, - withPersonIdAdopted, -} from "../../../src/contexts/telemetry/domain/person-resolution.js"; -import type { PersonIdentity } from "../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; -import type { PersonIdentityStore } from "../../../src/contexts/telemetry/domain/ports/person-identity-store.js"; - -/** In-memory double for `PersonIdentityStore`. `throwOnRead` and `throwOnForget`, when set, are - * what `readStrict()` and `forget()` throw — a damaged, or an undeletable, identity file. */ -export class InMemoryPersonIdentityStore implements PersonIdentityStore { - mintCount = 0; - forgetCount = 0; - throwOnForget: Error | null = null; - /** The `path` `forget()` actually received: what proves a caller passed the preview's own - * path, never this double's fixed `filePath`. */ - forgetCalledWithPath: string | null = null; - /** Whether a file would be on disk. Distinct from `identity`: a real file holding an empty - * `person_id` parses to `null` while still existing. */ - filePresent: boolean; - throwOnRead: Error | null = null; - - constructor( - private identity: PersonIdentity | null = null, - private readonly nextPersonId = "minted-person-id" - ) { - this.filePresent = identity !== null; - } - - get filePath(): string { - return "/fake/home/.config/aidd/identity.json"; - } - - async read(): Promise { - return this.identity; - } - - async readStrict(): Promise { - if (this.throwOnRead) throw this.throwOnRead; - return this.identity; - } - - async mint(): Promise { - this.filePresent = true; - this.mintCount++; - this.identity = { personId: this.nextPersonId, origin: "minted", alsoMe: [] }; - return this.identity; - } - - async adopt(personId: string): Promise { - this.filePresent = true; - this.identity = withPersonIdAdopted(this.identity, personId); - return this.identity; - } - - async addAlsoMe(identity: string): Promise { - this.filePresent = true; - if (this.identity === null) throw new Error("no identity to add onto"); - this.identity = withAlsoMeAdded(this.identity, identity); - return this.identity; - } - - async removeAlsoMe(identity: string): Promise { - this.filePresent = true; - if (this.identity === null) throw new Error("no identity to remove from"); - this.identity = withAlsoMeRemoved(this.identity, identity); - return this.identity; - } - - async setDisplayName(identity: PersonIdentity, displayName: string): Promise { - this.filePresent = true; - this.identity = { ...identity, displayName }; - return this.identity; - } - - async forget(path: string): Promise { - this.forgetCalledWithPath = path; - if (this.throwOnForget) throw this.throwOnForget; - this.forgetCount++; - const wasThere = this.filePresent; - this.identity = null; - this.filePresent = false; - return wasThere; - } -} diff --git a/cli/tests/helpers/ports/in-memory-run-journal-reader.ts b/cli/tests/helpers/ports/in-memory-run-journal-reader.ts deleted file mode 100644 index f0478fbb8..000000000 --- a/cli/tests/helpers/ports/in-memory-run-journal-reader.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { - RunJournal, - RunJournalStore, -} from "../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; - -/** In-memory double for `RunJournalStore`. `runFileNames` is settable rather than derived, so - * a listing can name a file `list()` could never parse; `undeletable` stands in for a run file - * that refuses removal; `deletedFromDirs` and `listCalls` record what a caller actually did. */ -export class InMemoryRunJournalReader implements RunJournalStore { - readonly runsDir = "/fake/project/aidd_docs/runs"; - runFileNames: string[] = []; - /** Settable directly, like `runFileNames` and for the same reason: a refused journal is - * one `list()` never returns, so it cannot be derived from what this double holds. */ - foreignSchemaVersions: number[] = []; - readonly deletedFiles: string[] = []; - readonly deletedFromDirs: string[] = []; - readonly undeletable = new Set(); - listCalls = 0; - private readonly journals = new Map(); - - set(sessionId: string, journal: RunJournal): void { - this.journals.set(sessionId, journal); - } - - async read(sessionId: string): Promise { - return this.journals.get(sessionId) ?? null; - } - - async list(): Promise { - this.listCalls += 1; - return [...this.journals.values()]; - } - - async listRunFiles(): Promise { - return this.runFileNames; - } - - async listForeignSchemas(): Promise { - return this.foreignSchemaVersions; - } - - async deleteRunFile(dir: string, fileName: string): Promise { - if (this.undeletable.has(fileName)) throw new Error(`cannot delete ${fileName}`); - this.deletedFromDirs.push(dir); - this.runFileNames = this.runFileNames.filter((name) => name !== fileName); - this.deletedFiles.push(fileName); - } -} - -/** No run file for any session — every candidate falls through to unattributed, exactly as - * a session with telemetry enabled but no journal beside it would read. */ -export const NULL_RUN_JOURNAL_READER: RunJournalStore = { - runsDir: "/fake/project/aidd_docs/runs", - read: async () => null, - list: async () => [], - listRunFiles: async () => [], - listForeignSchemas: async () => [], - deleteRunFile: async () => {}, -}; diff --git a/cli/tests/helpers/ports/in-memory-task-backlog-reader.ts b/cli/tests/helpers/ports/in-memory-task-backlog-reader.ts deleted file mode 100644 index 2dc7e1e25..000000000 --- a/cli/tests/helpers/ports/in-memory-task-backlog-reader.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { TaskBacklogReader } from "../../../src/contexts/telemetry/domain/ports/task-backlog-reader.js"; -import type { TaskBacklogDeclaration } from "../../../src/contexts/telemetry/domain/task-backlog-link.js"; - -/** In-memory double for `TaskBacklogReader`: `{ kind: "none" }` for a path it holds nothing - * for, mirroring the port's own contract of never throwing. */ -export class InMemoryTaskBacklogReader implements TaskBacklogReader { - private readonly declarations = new Map(); - - set(taskFolderPath: string, declaration: TaskBacklogDeclaration): void { - this.declarations.set(taskFolderPath, declaration); - } - - async read(taskFolderPath: string): Promise { - return this.declarations.get(taskFolderPath) ?? { kind: "none" }; - } -} diff --git a/cli/tests/helpers/ports/in-memory-telemetry-sink.ts b/cli/tests/helpers/ports/in-memory-telemetry-sink.ts deleted file mode 100644 index 2b6c00d78..000000000 --- a/cli/tests/helpers/ports/in-memory-telemetry-sink.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { - TelemetrySink, - TelemetrySinkAppendResult, - TelemetrySinkPeriodRead, -} from "../../../src/contexts/telemetry/domain/ports/telemetry-sink.js"; -import { - type TelemetrySinkRecord, - telemetrySinkRecordDayKey, -} from "../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; - -function dayKey(at: Date): string { - return at.toISOString().slice(0, 10); -} - -function dayFileName(at: Date): string { - return `${dayKey(at)}.jsonl`; -} - -/** `deletedFromDirs` records every `dir` argument `deleteDayFile` received, which is what - * proves a caller passed the preview's own path and never this double's `rootDir`. */ -export class InMemoryTelemetrySink implements TelemetrySink { - /** Settable, so a test can stand in for a machine that located its figures either way. */ - locatedBy: TelemetrySink["locatedBy"] = "default"; - readonly rootDir = "/fake/telemetry"; - readonly files = new Map(); - readonly deletedFiles: string[] = []; - readonly deletedFromDirs: string[] = []; - unwritable = false; - undeletable = new Set(); - - async ensureWritable(): Promise { - if (this.unwritable) throw new Error("sink directory not writable"); - } - - async appendRecord(record: TelemetrySinkRecord, at: Date): Promise { - const fileName = dayFileName(at); - const dayFileIsNew = !this.files.has(fileName); - const records = this.files.get(fileName) ?? []; - records.push(record); - this.files.set(fileName, records); - return { filePath: `${this.rootDir}/${fileName}`, dayFileIsNew }; - } - - async listDayFiles(): Promise { - return [...this.files.keys()].sort(); - } - - async deleteDayFile(dir: string, fileName: string): Promise { - if (this.undeletable.has(fileName)) throw new Error(`cannot delete ${fileName}`); - this.deletedFromDirs.push(dir); - this.files.delete(fileName); - this.deletedFiles.push(fileName); - } - - readonly vendorsRead: string[] = []; - - async readRecordsForVendor(vendorId: string): Promise { - this.vendorsRead.push(vendorId); - return [...this.files.values()].flat().filter((record) => record.vendor_id === vendorId); - } - - /** Selects through the same domain derivation the real adapter uses, so the two cannot - * disagree on a non-UTC offset. Nothing unparseable is held, so skipped is always zero. */ - async readRecordsInPeriod(fromDay: Date, toDay: Date): Promise { - const [fromKey, toKey] = [dayKey(fromDay), dayKey(toDay)].sort(); - const records: TelemetrySinkRecord[] = []; - const undated: TelemetrySinkRecord[] = []; - const projects = new Set(); - const steps = new Set(); - const models = new Set(); - for (const record of [...this.files.values()].flat()) { - if (record.project_id !== undefined) projects.add(record.project_id); - if (record.step !== undefined) steps.add(record.step); - if (record.model !== undefined) models.add(record.model); - const key = telemetrySinkRecordDayKey(record); - if (key === undefined) undated.push(record); - else if (key >= fromKey && key <= toKey) records.push(record); - } - return { records, undated, skippedLines: 0, knownValues: { projects, steps, models } }; - } -} diff --git a/cli/tests/helpers/ports/stub-ai-tool.ts b/cli/tests/helpers/ports/stub-ai-tool.ts index 91f8ccfb4..c7886a650 100644 --- a/cli/tests/helpers/ports/stub-ai-tool.ts +++ b/cli/tests/helpers/ports/stub-ai-tool.ts @@ -9,8 +9,6 @@ export function stubAiTool(toolId: AiToolId, capabilities: unknown): AiTool content, }; diff --git a/cli/tests/helpers/ports/stub-telemetry-evidence-reader.ts b/cli/tests/helpers/ports/stub-telemetry-evidence-reader.ts deleted file mode 100644 index b12bc3eb7..000000000 --- a/cli/tests/helpers/ports/stub-telemetry-evidence-reader.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { - TelemetryEvidenceReader, - TelemetrySwitchSetupRead, - TelemetryUnrecognisedPayload, -} from "../../../src/contexts/telemetry/domain/ports/telemetry-evidence-reader.js"; -import type { TelemetryExportLeftover } from "../../../src/contexts/telemetry/domain/telemetry-export-leftover.js"; -import type { TelemetryRecorderDeclarationSetup } from "../../../src/contexts/telemetry/domain/telemetry-setup.js"; - -/** `TelemetryEvidenceReader` without a real config file on disk. Every field defaults to the - * ordinary clean-machine state, so a test sets only the one it cares about. */ -export class StubTelemetryEvidenceReader implements TelemetryEvidenceReader { - enabled = true; - unrecognisedPayload: TelemetryUnrecognisedPayload | null = null; - leftoverExport: readonly TelemetryExportLeftover[] = []; - switchSetup: TelemetrySwitchSetupRead = { - path: "/repo/.aidd/config.json", - enabled: false, - readable: true, - }; - recorderDeclaration: TelemetryRecorderDeclarationSetup = { - declared: false, - declaredAt: [], - locationsChecked: ["/repo/.aidd/manifest.json"], - unreadable: [], - }; - - async isTelemetryEnabled(): Promise { - return this.enabled; - } - - async readUnrecognisedPayload(): Promise { - return this.unrecognisedPayload; - } - - async findLeftoverExportConfig(): Promise { - return this.leftoverExport; - } - - async readSwitchSetup(): Promise { - return this.switchSetup; - } - - async readRecorderDeclaration(): Promise { - return this.recorderDeclaration; - } -} diff --git a/cli/tests/helpers/telemetry-journal-hook.ts b/cli/tests/helpers/telemetry-journal-hook.ts deleted file mode 100644 index 2c3c75d3c..000000000 --- a/cli/tests/helpers/telemetry-journal-hook.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { createRequire } from "node:module"; -import { join } from "node:path"; -import { REPOSITORY_ROOT } from "./repository-root.js"; - -const hookLib = (name: string): string => - join(REPOSITORY_ROOT, "plugins", "aidd-telemetry", "hooks", "lib", name); - -/** The journal hook is zero-dependency CommonJS copied verbatim into user projects, so it ships - * no types and production code cannot import it. Tests reach it here, declaring only the - * surface they exercise, so a name the hook stops exporting fails loudly. */ -interface JournalRepoModule { - getRepoRoot(cwd: string): string | null; - getRemoteUrl(repoRoot: string): string | null; - parseOwnerRepoFromRemote(remoteUrl: string | null): string | null; - sanitizeProjectId(projectId: string): string; - sanitizePathSegment(segment: string): string; - deriveProjectId(repoRoot: string): string; - telemetryEnabled(repoRoot: string): boolean; - personRefusesTelemetry(): boolean; -} - -export const journalRepo: JournalRepoModule = createRequire(import.meta.url)(hookLib("repo.cjs")); - -/** The same reach into `record.cjs`, for the one derivation the reader side must agree with: a - * Codex session's identity, taken from the rollout the hook is told the session writes. */ -interface JournalRecordModule { - codexSessionIdFromTranscriptPath(transcriptPath: unknown): string | undefined; - readSessionId(host: string, payload: Record): string | undefined; - /** The schema the hook stamps on every `session_start`. Reached rather than copied, so the - * reader's notion of what it can read is pinned against the writer's, not a second constant. */ - SCHEMA_VERSION: number; -} - -export const journalRecord: JournalRecordModule = createRequire(import.meta.url)( - hookLib("record.cjs") -); - -/** The hook's own list of the hosts it writes for, so a conformance test can compare it - * against what the tool declarations claim rather than against a second copy of the list. */ -interface JournalHostModule { - DECLARED_HOSTS: ReadonlySet; -} - -export const journalHost: JournalHostModule = createRequire(import.meta.url)(hookLib("host.cjs")); - -/** The hook's file-writes module. `WRITTEN_PATH_EXTRACTOR_BY_HOST` is exposed so a test can - * assert which hosts are covered rather than assume all of them are. */ -interface JournalFileWritesModule { - WRITTEN_PATH_EXTRACTOR_BY_HOST: Readonly>; - taskFolderRelativePath(repoRoot: string, rawPath: string): string | null; - handleFileWritten( - payload: Record, - host: string, - sessionId: string | undefined - ): void; -} - -export const journalFileWrites: JournalFileWritesModule = createRequire(import.meta.url)( - hookLib("file-writes.cjs") -); - -/** The hook's declaration module: a task can be declared on any host `journal.cjs`'s - * `tool-used` dispatch reaches, and this is the one place that reads a call's own arguments. */ -interface JournalTaskDeclaredModule { - declaredTaskPath(payload: Record): string | null; - handleTaskDeclared( - payload: Record, - host: string, - sessionId: string | undefined - ): void; -} - -export const journalTaskDeclared: JournalTaskDeclaredModule = createRequire(import.meta.url)( - hookLib("task-declared.cjs") -); - -/** The hook's own trailer repair, exposed for the one thing the hook's own suite cannot prove: - * that the line it writes is character for character the line the CLI writes. */ -interface JournalTrailerRepairModule { - repairCommitTrailerHook(hooksDir: string | undefined, gitDir?: string): string; - hookLine(delegatePath: string): string; - DELEGATE_FILE: string; - HOOK_FILE: string; - HOOK_HEADER: string; -} - -export const journalTrailerRepair: JournalTrailerRepairModule = createRequire(import.meta.url)( - hookLib("trailer-repair.cjs") -); diff --git a/cli/tests/helpers/throwaway-profile.unit.test.ts b/cli/tests/helpers/throwaway-profile.unit.test.ts index d35522bac..38bdfa475 100644 --- a/cli/tests/helpers/throwaway-profile.unit.test.ts +++ b/cli/tests/helpers/throwaway-profile.unit.test.ts @@ -1,11 +1,11 @@ import { homedir, tmpdir } from "node:os"; import { describe, expect, it } from "vitest"; -import { resolveAiddConfigDir, resolveHomeDir } from "../../src/kernel/reading/home-dir.js"; +import { resolveHomeDir } from "../../src/kernel/reading/home-dir.js"; import { userConfigDir } from "../../src/runtime/user-config-dir.js"; describe("the profile a test run sees", () => { it("is a home under the temp directory, whichever route resolves it", () => { - for (const resolved of [homedir(), resolveHomeDir(), resolveAiddConfigDir(), userConfigDir()]) { + for (const resolved of [homedir(), resolveHomeDir(), userConfigDir()]) { expect(resolved.startsWith(tmpdir()), resolved).toBe(true); } }); diff --git a/cli/tests/integration/telemetry-trailer-line-agrees.integration.test.ts b/cli/tests/integration/telemetry-trailer-line-agrees.integration.test.ts deleted file mode 100644 index fa5d959eb..000000000 --- a/cli/tests/integration/telemetry-trailer-line-agrees.integration.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_HOOK_HEADER, - sessionTrailerHookLine, -} from "../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; -import { journalTrailerRepair } from "../helpers/telemetry-journal-hook.js"; - -/** The one literal this feature spells twice, held to itself across the language boundary: the - * CLI writes the call site, the hook restores it from CommonJS that cannot import the CLI. - * Diverged, a repaired repository stops trailering while both sides pass their own tests. */ -describe("the hook and the CLI spell the call site identically", () => { - it.each([ - ["a POSIX path", "/home/dev/repo/.git/hooks"], - ["a path with spaces", "/Users/dev/My Projects/repo/.git/hooks"], - [ - "a Windows path, where the separator is the whole difficulty", - "C:\\Users\\dev\\repo\\.git\\hooks", - ], - ])("agrees on %s", (_shape, hooksDir) => { - const delegatePath = `${hooksDir}/${SESSION_TRAILER_DELEGATE_FILE}`; - - expect(journalTrailerRepair.hookLine(delegatePath)).toBe(sessionTrailerHookLine(delegatePath)); - }); - - it("agrees on the delegate's filename, which decides where each side looks", () => { - expect(journalTrailerRepair.DELEGATE_FILE).toBe(SESSION_TRAILER_DELEGATE_FILE); - }); - - it("agrees on the hook's own filename", () => { - expect(journalTrailerRepair.HOOK_FILE).toBe("prepare-commit-msg"); - }); - - /** The header a hook written from scratch starts with, and the one line that does not count as - * somebody else's content when read back. Disagreeing, `check` calls this project's own hook - * somebody else's. */ - it("agrees on the header a hook written from scratch starts with", () => { - expect(journalTrailerRepair.HOOK_HEADER).toBe(SESSION_TRAILER_HOOK_HEADER); - }); -}); - -/** The words the repair answers with, exercised through the module: a caller has to tell a - * directory the repair declined from one that had nothing to do. The delegate is really - * written, since without it every call returns `"no-delegate"` and never reaches that guard. */ -describe("what the repair reports about a directory it will not write to", () => { - let root: string; - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), "aidd-trailer-declines-")); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - async function hooksAt(relative: string): Promise { - const at = join(root, relative); - await mkdir(at, { recursive: true }); - await writeFile(join(at, SESSION_TRAILER_DELEGATE_FILE), "#!/bin/sh\nexit 0\n"); - return at; - } - - it("declines a hooks directory outside the git directory, delegate and all", async () => { - const shared = await hooksAt(".githooks"); - await mkdir(join(root, ".git"), { recursive: true }); - - expect(journalTrailerRepair.repairCommitTrailerHook(shared, join(root, ".git"))).toBe( - "not-ours-to-write" - ); - }); - - it("repairs one inside it", async () => { - const inside = await hooksAt(join(".git", "hooks")); - - expect(journalTrailerRepair.repairCommitTrailerHook(inside, join(root, ".git"))).toBe( - "repaired" - ); - }); - - it("has nothing to do without a hooks directory at all", () => { - expect(journalTrailerRepair.repairCommitTrailerHook("", join(root, ".git"))).toBe( - "no-delegate" - ); - }); -}); diff --git a/cli/tests/kernel/describe-error.unit.test.ts b/cli/tests/kernel/describe-error.unit.test.ts index 360ff52db..048e0da7b 100644 --- a/cli/tests/kernel/describe-error.unit.test.ts +++ b/cli/tests/kernel/describe-error.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { describeError, errorMessage } from "../../src/kernel/describe-error.js"; +import { describeError } from "../../src/kernel/describe-error.js"; describe("describeError", () => { it("answers the code of a filesystem failure", () => { @@ -20,13 +20,3 @@ describe("describeError", () => { expect(describeError({ code: "ENOENT" })).toBe("[object Object]"); }); }); - -describe("errorMessage", () => { - it("answers an Error's message", () => { - expect(errorMessage(new Error("bad json"))).toBe("bad json"); - }); - - it("stringifies a thrown value that is not an Error", () => { - expect(errorMessage(42)).toBe("42"); - }); -}); diff --git a/cli/tests/kernel/errors.unit.test.ts b/cli/tests/kernel/errors.unit.test.ts index 08e52e368..06cfa7b1a 100644 --- a/cli/tests/kernel/errors.unit.test.ts +++ b/cli/tests/kernel/errors.unit.test.ts @@ -605,73 +605,6 @@ describe("every error in the catalog", () => { name: "ToolNotInstalledError", message: "The target tool 'cursor' is not installed.", }, - { - build: () => new errors.UnknownTelemetrySinkSchemaVersionError(7), - name: "UnknownTelemetrySinkSchemaVersionError", - message: "Unknown telemetry sink schema version '7' — refusing to guess its shape.", - }, - { - build: () => new errors.OpencodeExportError("export timed out"), - name: "OpencodeExportError", - message: "export timed out", - }, - { - build: () => new errors.InvalidReportDayError("--from", "yesterday"), - name: "InvalidReportDayError", - message: "Invalid --from 'yesterday'. Expected a UTC day, as YYYY-MM-DD.", - }, - { - build: () => new errors.InvalidReportSpanError("0", 90), - name: "InvalidReportSpanError", - message: "Invalid --days '0'. Expected an integer between 1 and 90.", - }, - { - build: () => new errors.UnreadableIdentityFileError("/id.json", "EISDIR"), - name: "UnreadableIdentityFileError", - message: "Could not read the identity file at /id.json (EISDIR).", - }, - { - build: () => - new errors.TelemetryProjectScopeRequiresYesError("telemetry on", ".aidd/config.json"), - name: "TelemetryProjectScopeRequiresYesError", - message: - "telemetry on writes the git-tracked .aidd/config.json, turning telemetry on for everyone who clones. Pass --yes to confirm.", - }, - { - build: () => new errors.EmptyDisplayNameError(), - name: "EmptyDisplayNameError", - message: "`aidd telemetry identity use --name` needs a non-empty value.", - }, - { - build: () => new errors.IdentityRequiredToLinkError(), - name: "IdentityRequiredToLinkError", - message: "No identity to link onto yet. Run `aidd telemetry identity use` first.", - }, - { - build: () => new errors.EmptyIdentifierError("link"), - name: "EmptyIdentifierError", - message: "`aidd telemetry identity link` needs a non-empty value.", - }, - { - build: () => new errors.TelemetrySinkUnwritableError("/sink", new Error("EACCES")), - name: "TelemetrySinkUnwritableError", - message: "Telemetry sink directory is not writable: /sink (EACCES)", - }, - { - build: () => new errors.TelemetrySinkUnwritableError("/sink", "disk full"), - name: "TelemetrySinkUnwritableError", - message: "Telemetry sink directory is not writable: /sink (disk full)", - }, - { - build: () => new errors.IdentityWriteError("/id.json", new Error("ENOSPC")), - name: "IdentityWriteError", - message: "Could not write the identity file at /id.json (ENOSPC).", - }, - { - build: () => new errors.IdentityWriteError("/id.json", "EACCES", "remove"), - name: "IdentityWriteError", - message: "Could not remove the identity file at /id.json (EACCES).", - }, ]; it.each(catalogue.map((entry) => [entry.name, entry] as const))( diff --git a/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts b/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts deleted file mode 100644 index 3b6a331d9..000000000 --- a/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { resolveAiddConfigDir, resolveHomeDir } from "../../../src/kernel/reading/home-dir.js"; - -describe("resolveAiddConfigDir", () => { - it.skipIf(process.platform === "win32")( - "sits under .config in the home directory on POSIX", - () => { - expect(resolveAiddConfigDir()).toBe(join(resolveHomeDir(), ".config", "aidd")); - } - ); -}); diff --git a/cli/tests/kernel/reading/confined-file-name.unit.test.ts b/cli/tests/kernel/reading/confined-file-name.unit.test.ts deleted file mode 100644 index 365e778b1..000000000 --- a/cli/tests/kernel/reading/confined-file-name.unit.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isBareFileName } from "../../../src/kernel/reading/confined-file-name.js"; - -describe("isBareFileName", () => { - it("accepts a plain file name", () => { - expect(isBareFileName("manifest.json")).toBe(true); - }); - - it("refuses an empty name", () => { - expect(isBareFileName("")).toBe(false); - }); - - it("refuses the directory itself", () => { - expect(isBareFileName(".")).toBe(false); - }); - - it("refuses the parent directory", () => { - expect(isBareFileName("..")).toBe(false); - }); - - it("refuses a name that walks out of the directory", () => { - expect(isBareFileName("../manifest.json")).toBe(false); - }); - - it("refuses a nested path", () => { - expect(isBareFileName("sub/manifest.json")).toBe(false); - }); - - it("refuses an absolute path", () => { - expect(isBareFileName("/etc/passwd")).toBe(false); - }); -}); diff --git a/cli/tests/kernel/reading/json-file.unit.test.ts b/cli/tests/kernel/reading/json-file.unit.test.ts index 010004ae4..1da8d36d0 100644 --- a/cli/tests/kernel/reading/json-file.unit.test.ts +++ b/cli/tests/kernel/reading/json-file.unit.test.ts @@ -1,24 +1,5 @@ import { describe, expect, it } from "vitest"; -import { asPlainObjectOrEmpty, isErrnoException } from "../../../src/kernel/reading/json-file.js"; - -describe("asPlainObjectOrEmpty", () => { - it("passes a plain object through unchanged", () => { - const value = { a: 1 }; - expect(asPlainObjectOrEmpty(value)).toBe(value); - }); - - it("reads null as an empty object", () => { - expect(asPlainObjectOrEmpty(null)).toStrictEqual({}); - }); - - it("reads an array as an empty object", () => { - expect(asPlainObjectOrEmpty([1])).toStrictEqual({}); - }); - - it("reads a primitive as an empty object", () => { - expect(asPlainObjectOrEmpty("text")).toStrictEqual({}); - }); -}); +import { isErrnoException } from "../../../src/kernel/reading/json-file.js"; describe("isErrnoException", () => { it("recognises an Error carrying a code", () => { diff --git a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts deleted file mode 100644 index 5c7b51946..000000000 --- a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { Command } from "commander"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import { buildCostReport } from "../../../src/contexts/telemetry/domain/cost-report.js"; -import { DEFAULT_REPORT_DAYS } from "../../../src/contexts/telemetry/domain/report-period.js"; -import type { TelemetryRemovalPreview } from "../../../src/contexts/telemetry/domain/telemetry-removal.js"; -import { ARTEFACT_AXES } from "../../../src/presentation/display/cost-report-artefact.js"; - -const telemetryOn = vi.fn(); -const telemetryOff = vi.fn(); -const readLocalCost = vi.fn(); -const reportCost = vi.fn(); -const diagnose = vi.fn(); -const forgetPreview = vi.fn(); -const forgetRemove = vi.fn(); -const identityStatus = vi.fn(); -const identityUse = vi.fn(); -const identityOff = vi.fn(); -const identityLink = vi.fn(); -const identityUnlink = vi.fn(); - -vi.mock("../../../src/runtime/wiring/framework.js", () => ({ - createDeps: vi.fn(async () => ({ - telemetryOnUseCase: { execute: telemetryOn }, - telemetryOffUseCase: { execute: telemetryOff }, - readLocalCostUseCase: { execute: readLocalCost }, - reportCostUseCase: { execute: reportCost }, - diagnoseTelemetryUseCase: { execute: diagnose }, - forgetTelemetryUseCase: { preview: forgetPreview, remove: forgetRemove }, - personIdentityUseCase: { - status: identityStatus, - use: identityUse, - off: identityOff, - link: identityLink, - unlink: identityUnlink, - }, - telemetrySink: { locatedBy: "default", rootDir: "/records" }, - })), - createMenuDeps: vi.fn(), -})); - -const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); -const { registerTelemetryCommand } = await import( - "../../../src/presentation/commands/telemetry.js" -); - -const PROJECT_ROOT = process.cwd(); - -function emptyReport() { - return buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-17", - records: [], - journals: [], - declaredTools: [ - { - tool: "claude", - coverage: "covered", - capability: { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, - }, - }, - ], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }); -} - -const NOTHING_TO_REMOVE: TelemetryRemovalPreview = { - journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, - sink: { scope: "machine", path: "/records", dayFileNames: [] }, - identity: { scope: "machine", path: "/h/identity.json", present: false, unreadable: false }, - history: { certainty: "none" }, -}; - -const ONE_RUN_FILE: TelemetryRemovalPreview = { - ...NOTHING_TO_REMOVE, - journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: ["a.jsonl"] }, -}; - -let written: string[] = []; - -beforeEach(() => { - vi.clearAllMocks(); - written = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - written.push(String(chunk)); - return true; - }); - vi.spyOn(process.stderr, "write").mockImplementation(() => true); - telemetryOn.mockResolvedValue({ switchPath: "/repo/.aidd/config.json", switchChanged: true }); - telemetryOff.mockResolvedValue({ switchPath: "/repo/.aidd/config.json", switchChanged: true }); - readLocalCost.mockResolvedValue({ sessions: [], toolReports: [] }); - reportCost.mockResolvedValue(emptyReport()); - diagnose.mockResolvedValue({ - gate: "measurement is off", - setup: checkSetup(), - leftoverExportConfig: [], - }); - forgetPreview.mockResolvedValue(NOTHING_TO_REMOVE); - forgetRemove.mockResolvedValue({ - journal: { removed: 0, failed: [] }, - sink: { removed: 0, failed: [] }, - identity: { removed: 0, failed: [] }, - history: { certainty: "none" }, - }); - identityStatus.mockResolvedValue({ filePath: "/h/identity.json", identity: null }); - identityUse.mockResolvedValue({ - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "minted", alsoMe: [] }, - outcome: "minted", - }); - identityOff.mockResolvedValue({ - filePath: "/h/identity.json", - removed: false, - discardedDamaged: false, - addedIdentifiersRemoved: 0, - }); - identityLink.mockResolvedValue({ - filePath: "/h/identity.json", - personId: "p-1", - identity: "machine-2", - alreadyListed: true, - }); - identityUnlink.mockResolvedValue({ - filePath: "/h/identity.json", - identity: "machine-2", - removed: false, - }); -}); - -afterEach(() => { - vi.restoreAllMocks(); - process.exitCode = undefined; -}); - -function checkSetup() { - return { - allowed: { - allowed: true, - readable: true, - location: "/repo/.aidd/config.json", - decidedBy: "project-switch", - }, - identity: { attached: false, path: "/h/identity.json", readable: true }, - recordsLocation: { path: "/records" }, - hostRegistration: { entries: [] }, - commitTrailer: { - delegate: "executable", - callSite: "present", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - }, - recorderDeclaration: { - declared: true, - declaredAt: ["/repo/.aidd/manifest.json"], - locationsChecked: ["/repo/.aidd/manifest.json"], - unreadable: [], - }, - versions: { cli: "5.2.2", plugin: { kind: "nothing-journalled" } }, - } as const; -} - -async function run(...args: string[]): Promise { - const program = new Command(); - program.exitOverride(); - registerTelemetryCommand(program); - await program.parseAsync(["node", "aidd", "telemetry", ...args]); - return written.join("").split("\n").slice(0, -1); -} - -describe("aidd telemetry on | off", () => { - it("passes the flag a person confirmed with, and prints what the switch became", async () => { - expect(await run("on", "--yes")).toEqual([ - "AIDD telemetry: on (/repo/.aidd/config.json)", - "/repo/.aidd/config.json is git-tracked — this applies to everyone who clones.", - ]); - expect(telemetryOn).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, confirmed: true }); - }); - - it("passes an unconfirmed run as unconfirmed, never as absent", async () => { - await run("on"); - - expect(telemetryOn).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, confirmed: false }); - }); - - it("asks off for nothing but the project, and prints what stays behind", async () => { - const lines = await run("off"); - - expect(telemetryOff).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT }); - expect(lines[0]).toBe("AIDD telemetry: off (/repo/.aidd/config.json)"); - }); -}); - -describe("aidd telemetry read", () => { - it("sweeps every journalled session when none was named", async () => { - const lines = await run("read"); - - expect(Object.keys(readLocalCost.mock.calls[0][0]).sort()).toEqual(["env", "projectRoot"]); - expect(lines).toEqual([" No session journalled yet — nothing to read."]); - }); - - it("narrows to the one session a person named", async () => { - await run("read", "--session", "s-1"); - - expect(readLocalCost).toHaveBeenCalledWith({ - projectRoot: PROJECT_ROOT, - env: process.env, - sessionId: "s-1", - }); - }); -}); - -describe("aidd telemetry report — what it asks for", () => { - it("resolves the period from the days given, and carries no filter nobody gave", async () => { - await run("report", "--from", "2026-08-17", "--to", "2026-08-19"); - - const asked = reportCost.mock.calls[0][0]; - expect(asked.period).toEqual({ fromDay: "2026-08-17", toDay: "2026-08-19" }); - expect(Object.keys(asked).sort()).toEqual(["env", "filters", "period", "projectRoot"]); - expect(Object.keys(asked.filters)).toEqual([]); - }); - - it("carries the task and every filter a person named, each under its own key", async () => { - await run( - "report", - "--from", - "2026-08-17", - "--to", - "2026-08-19", - "--task", - "2026_08/x", - "--project", - "acme/widgets", - "--step", - "aidd-dev:02-implement", - "--model", - "opus", - "--tool", - "claude" - ); - - expect(reportCost).toHaveBeenCalledWith({ - period: { fromDay: "2026-08-17", toDay: "2026-08-19" }, - projectRoot: PROJECT_ROOT, - env: process.env, - task: "2026_08/x", - filters: { - project: "acme/widgets", - step: "aidd-dev:02-implement", - model: "opus", - tool: "claude", - }, - }); - }); - - it("counts back from the last day when a span, not a first day, was given", async () => { - await run("report", "--to", "2026-08-19", "--days", "3"); - - expect(reportCost).toHaveBeenCalledWith( - expect.objectContaining({ period: { fromDay: "2026-08-17", toDay: "2026-08-19" } }) - ); - }); -}); - -describe("aidd telemetry report — the rendering it picks", () => { - const PERIOD = ["--from", "2026-08-17", "--to", "2026-08-17"]; - - it("prints the terminal rendering when neither shape was asked for", async () => { - expect(await run("report", ...PERIOD)).toEqual([ - "period 2026-08-17 to 2026-08-17", - "", - " sessions nothing in this period", - " requests nothing in this period", - "", - " by tool", - " Claude Code nothing in this period", - "", - " by day", - " 2026-08-17 nothing in this period", - ]); - }); - - it("prints one parseable object, and nothing else, for --json", async () => { - const lines = await run("report", ...PERIOD, "--json"); - - expect(JSON.parse(lines.join("\n"))).toMatchObject({ - period: { from_day: "2026-08-17", to_day: "2026-08-17" }, - }); - }); - - it("prints the one axis asked for as a table, never the whole report", async () => { - expect(await run("report", ...PERIOD, "--axis", "total")).toEqual([ - "period 2026-08-17 to 2026-08-17 — axis: total", - "", - "nothing in this period", - ]); - }); - - it("prefers the object over the table when both were asked for", async () => { - const lines = await run("report", ...PERIOD, "--json", "--axis", "total"); - - expect(() => JSON.parse(lines.join("\n"))).not.toThrow(); - }); -}); - -describe("aidd telemetry check", () => { - it("asks for this project and its environment, then prints the report", async () => { - const lines = await run("check"); - - expect(diagnose).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, env: process.env }); - expect(lines.at(-1)).toBe(" measurement is off"); - }); - - // A gated run judged nothing, so it can find nothing wanting: failing the process there - // would report a project that switched measurement off as broken. - it("leaves the exit code alone when the run was gated before judging", async () => { - diagnose.mockResolvedValue({ - gate: "measurement is off", - setup: checkSetup(), - claims: [{ claim: "hook-fired", verdict: "fail", reason: "none", detail: "none" }], - leftoverExportConfig: [], - }); - - await run("check"); - - expect(process.exitCode).toBeUndefined(); - }); - - it("fails the process on one claim found wanting among claims that held", async () => { - diagnose.mockResolvedValue({ - setup: checkSetup(), - claims: [ - { claim: "hook-fired", verdict: "ok", reason: "session-anchored", detail: "1 run file" }, - { - claim: "records-join", - verdict: "fail", - reason: "all-unattributed", - detail: "none", - }, - ], - uncovered: [], - leftoverExportConfig: [], - }); - - await run("check"); - - expect(process.exitCode).toBe(1); - }); - - it("leaves the exit code alone when every judged claim held", async () => { - diagnose.mockResolvedValue({ - setup: checkSetup(), - claims: [{ claim: "hook-fired", verdict: "ok", reason: "fired", detail: "1 run file(s)" }], - uncovered: [], - leftoverExportConfig: [], - }); - - await run("check"); - - expect(process.exitCode).toBeUndefined(); - }); -}); - -describe("aidd telemetry forget", () => { - it("shows the preview and removes nothing when there was never anything to remove", async () => { - const lines = await run("forget", "--yes"); - - expect(forgetPreview).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT }); - expect(forgetRemove).not.toHaveBeenCalled(); - expect(lines[0]).toContain("nothing was ever measured here"); - }); - - it("refuses without the flag, after showing exactly what would go", async () => { - forgetPreview.mockResolvedValue(ONE_RUN_FILE); - - const lines = await run("forget"); - - expect(forgetRemove).not.toHaveBeenCalled(); - expect(lines[0]).toBe("This would remove:"); - expect(lines.at(-1)).toBe( - "Nothing removed. Pass --yes to remove exactly what is listed above." - ); - }); - - it("removes exactly the preview it showed, once the flag confirms it", async () => { - forgetPreview.mockResolvedValue(ONE_RUN_FILE); - - const lines = await run("forget", "--yes"); - - expect(forgetRemove).toHaveBeenCalledWith(ONE_RUN_FILE); - expect(lines).toContain("AIDD telemetry: removed"); - }); -}); - -describe("aidd telemetry identity", () => { - it("answers the bare noun with the state, never a help screen", async () => { - const lines = await run("identity"); - - expect(identityStatus).toHaveBeenCalledWith(); - expect(lines).toEqual(["AIDD identity: off - records carry no person"]); - }); - - it("mints without an identifier or a name when neither was given", async () => { - await run("identity", "use"); - - expect(Object.keys(identityUse.mock.calls[0][0])).toEqual([]); - }); - - it("takes the identifier given, alone", async () => { - await run("identity", "use", "p-9"); - - expect(Object.keys(identityUse.mock.calls[0][0])).toEqual(["identifier"]); - expect(identityUse).toHaveBeenCalledWith({ identifier: "p-9" }); - }); - - it("attaches a display name to whichever identifier the call settles on", async () => { - await run("identity", "use", "--name", "Ada"); - - expect(Object.keys(identityUse.mock.calls[0][0])).toEqual(["displayName"]); - expect(identityUse).toHaveBeenCalledWith({ displayName: "Ada" }); - }); - - it("carries both an identifier and a name when both were given", async () => { - await run("identity", "use", "p-9", "--name", "Ada"); - - expect(identityUse).toHaveBeenCalledWith({ identifier: "p-9", displayName: "Ada" }); - }); - - it("opts out with no argument at all", async () => { - const lines = await run("identity", "off"); - - expect(identityOff).toHaveBeenCalledWith(); - expect(lines).toEqual(["AIDD identity: already off - nothing to withdraw"]); - }); - - it("links and unlinks the identifier named on the command line", async () => { - await run("identity", "link", "machine-2"); - await run("identity", "unlink", "machine-2"); - - expect(identityLink).toHaveBeenCalledWith("machine-2"); - expect(identityUnlink).toHaveBeenCalledWith("machine-2"); - }); -}); - -describe("aidd telemetry — a use case that throws", () => { - it.each([ - [["on"], telemetryOn], - [["off"], telemetryOff], - [["read"], readLocalCost], - [["report", "--from", "2026-08-17", "--to", "2026-08-17"], reportCost], - [["check"], diagnose], - [["forget"], forgetPreview], - [["identity"], identityStatus], - [["identity", "use"], identityUse], - [["identity", "off"], identityOff], - [["identity", "link", "m-2"], identityLink], - [["identity", "unlink", "m-2"], identityUnlink], - ])("names the failure of %j on stderr and fails the process", async (args, useCase) => { - useCase.mockRejectedValue(new Error("boom")); - const errors: string[] = []; - vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { - errors.push(String(chunk)); - return true; - }); - const exit = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("exited"); - }); - - await expect(run(...args)).rejects.toThrow("exited"); - - expect(errors.join("")).toBe("Error: boom\n"); - expect(exit).toHaveBeenCalledWith(1); - }); -}); - -describe("aidd telemetry — the help surface", () => { - function telemetryCommand(): Command { - const program = new Command(); - registerTelemetryCommand(program); - const telemetry = program.commands.find((command) => command.name() === "telemetry"); - if (telemetry === undefined) throw new Error("telemetry command was not registered"); - return telemetry; - } - - function describedCommands(parent: Command): [string, string][] { - return parent.commands.flatMap((command): [string, string][] => [ - [command.name(), command.description()], - ...describedCommands(command), - ]); - } - - function optionsOf(path: readonly string[]): [string, string][] { - let command = telemetryCommand(); - for (const name of path) { - const child = command.commands.find((candidate) => candidate.name() === name); - if (child === undefined) throw new Error(`no subcommand ${name}`); - command = child; - } - return command.options.map((option) => [option.flags, option.description]); - } - - it("describes the group by what a person decides with it", () => { - expect(telemetryCommand().description()).toBe("Control whether AIDD may measure this project"); - }); - - it("describes every subcommand, in the order they are registered", () => { - expect(describedCommands(telemetryCommand())).toEqual([ - ["on", "Turn on the AIDD telemetry switch and git-ignore the run journal"], - [ - "read", - "Read what sessions cost from the files their tools already wrote, with no process running", - ], - ["identity", "Whether this person's own identifier is attached to records read locally"], - [ - "use", - "Mint this person's identifier, or take one minted on another machine. --name attaches a display name", - ], - ["off", "Opt out: new records carry no person, from now on"], - [ - "link", - "Add an identifier this person cannot choose onto this same person - one row, not two, in a report", - ], - ["unlink", "Withdraw an added identifier from this person"], - ["check", "Check whether the measurement chain is actually recording for this project"], - [ - "report", - "Report what a period, or one task inside it, cost — tokens, models and steps, with how strongly each was attributed", - ], - [ - "off", - "Turn off the AIDD telemetry switch, warning if a tool's own settings file still exports", - ], - [ - "forget", - "Irreversibly remove what this tool measured: this project's run journal, this " + - "machine's stored records, and this machine's identity file", - ], - ]); - }); - - it("says what confirming on and confirming forget each mean", () => { - expect(optionsOf(["on"])).toEqual([ - [ - "--yes", - "Confirm writing the git-tracked switch — this turns measurement on for everyone who clones", - ], - ]); - expect(optionsOf(["forget"])).toEqual([ - ["--yes", "Confirm removal after seeing what would go — without it, nothing is removed"], - ]); - }); - - it("says what omitting read's own session means", () => { - expect(optionsOf(["read"])).toEqual([ - [ - "--session ", - "One session to read. Omitted, every session the run journal knows is read", - ], - ]); - }); - - it("names every day, filter and shape report accepts, and every axis by name", () => { - expect(optionsOf(["report"])).toEqual([ - ["--from ", "First UTC day to report, as YYYY-MM-DD"], - ["--to ", "Last UTC day to report, as YYYY-MM-DD (default today)"], - [ - "--days ", - `How many days back to report, ending at --to (default ${DEFAULT_REPORT_DAYS})`, - ], - [ - "--task ", - "Restrict to the sessions that wrote into this task, as /", - ], - ["--project ", "Restrict to this project"], - ["--step ", "Restrict to this step"], - ["--model ", "Restrict to this model"], - ["--tool ", "Restrict to this tool"], - [ - "--axis ", - `Print one axis as a table to paste elsewhere: ${ARTEFACT_AXES.join(" | ")}`, - ], - ["--json", "Print one object a program can parse, instead of text for a person"], - ]); - }); - - it("names the display name identity use attaches, and asks nothing else anywhere", () => { - expect(optionsOf(["identity", "use"])).toEqual([ - ["--name ", "A display name for whichever identifier this call settles on"], - ]); - expect(optionsOf(["identity"])).toEqual([]); - expect(optionsOf(["check"])).toEqual([]); - }); -}); - -describe("aidd telemetry — how every subcommand builds its graph", () => { - it.each([ - ["on"], - ["off"], - ["read"], - ["report", "--from", "2026-08-17", "--to", "2026-08-17"], - ["check"], - ["forget"], - ["identity"], - ["identity", "use"], - ["identity", "off"], - ["identity", "link", "m-2"], - ["identity", "unlink", "m-2"], - ])("hands %j's own graph this run's verbosity, never an empty option set", async (...args) => { - await run(...args); - - expect(vi.mocked(createDeps)).toHaveBeenCalledWith( - PROJECT_ROOT, - { verbose: false }, - expect.anything() - ); - }); -}); diff --git a/cli/tests/presentation/display/cost-report-artefact.unit.test.ts b/cli/tests/presentation/display/cost-report-artefact.unit.test.ts deleted file mode 100644 index 829dc5321..000000000 --- a/cli/tests/presentation/display/cost-report-artefact.unit.test.ts +++ /dev/null @@ -1,1040 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import { - buildCostReport, - type CostReportInput, -} from "../../../src/contexts/telemetry/domain/cost-report.js"; -import { toCostReportEnvelope } from "../../../src/contexts/telemetry/domain/cost-report-envelope.js"; -import { bareOrchestratingSkillNames } from "../../../src/contexts/telemetry/domain/flow-attribution.js"; -import type { PersonIdentity } from "../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; -import type { TelemetrySinkRecord } from "../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { - ARTEFACT_AXES, - buildCostReportArtefact, - isArtefactAxis, -} from "../../../src/presentation/display/cost-report-artefact.js"; -import { printCostReport } from "../../../src/presentation/display/cost-report-display.js"; -import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; - -const NO_CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function request(overrides: Partial = {}): TelemetrySinkRecord { - return { ...BASE, ...overrides }; -} - -function envelopeOf(overrides: Partial = {}) { - return toCostReportEnvelope( - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }) - ); -} - -function onePersonMapping(): PersonIdentity { - return { personId: "person-a", origin: "adopted", alsoMe: ["machine-1"], displayName: "Ada" }; -} - -describe("buildCostReportArtefact", () => { - // A resolution `personLabel` does not name must not fall through to the no-identifier - // label: a value added to `PersonResolution` would reach a reader as its opposite. - it("names a row this machine's identity claims after that person, not as nobody", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", event_timestamp: "2026-08-17T10:00:00Z" })], - identity: onePersonMapping(), - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("Ada"); - expect(artefact).not.toContain("nobody opted in"); - }); - - it("lists person among the known axes", () => { - expect(ARTEFACT_AXES).toContain("person"); - expect(isArtefactAxis("person")).toBe(true); - }); - - it("answers the prompt axis with one dated row per prompt, and the remainder last", () => { - const artefact = buildCostReportArtefact( - envelopeOf({ - records: [ - request({ - turn_id: "a", - prompt_id: "p-1", - cost_usd: 2, - event_timestamp: "2026-08-18T09:00:00Z", - }), - request({ turn_id: "b", cost_usd: 1, event_timestamp: "2026-08-18T10:00:00Z" }), - ], - }), - "prompt" - ); - - expect(artefact).toContain("| Prompt | Started at | Total |"); - const lines = artefact - .split("\n") - .filter((line) => line.startsWith("| p-1") || line.includes("no prompt named")); - expect(lines[0]).toContain("| p-1 | 2026-08-18T09:00:00Z |"); - expect(lines[1]).toContain("| no prompt named | — |"); - }); - - it("refuses an unknown axis by name, listing the ones that exist", () => { - expect(() => buildCostReportArtefact(envelopeOf(), "bogus")).toThrow( - /Unknown axis 'bogus'.*person/su - ); - }); - - // A pasted table leaves the terminal behind, so it has to carry the switch being off itself. - it("names the project's switch being off in its own header, on every axis", () => { - const off = envelopeOf({ measurementEnabled: false }); - for (const axis of ARTEFACT_AXES) { - expect(buildCostReportArtefact(off, axis)).toContain("this project's switch is off"); - } - }); - - it("says nothing about the switch in the header when it is on", () => { - const on = envelopeOf({ measurementEnabled: true }); - expect(buildCostReportArtefact(on, "total")).not.toContain("switch is off"); - }); - - it("prints one row per person with the identities behind it, mapped rows first", () => { - const envelope = envelopeOf({ - identity: onePersonMapping(), - records: [request({ turn_id: "a", person_id: "machine-1" })], - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("Ada"); - expect(artefact).toContain("machine-1"); - }); - - it("prints two unplaced identifiers as two labelled rows, never one bucket", () => { - const envelope = envelopeOf({ - identity: onePersonMapping(), - records: [ - request({ turn_id: "a", person_id: "a-stranger" }), - request({ turn_id: "b", person_id: "another-stranger" }), - ], - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("a-stranger"); - expect(artefact).toContain("another-stranger"); - const unresolvedLines = artefact.split("\n").filter((line) => line.includes("unresolved")); - expect(unresolvedLines).toHaveLength(2); - }); - - // Only with no identity declared on this machine is "nobody opted in" true of a record - // carrying no identifier; with one declared, that record is this machine's own person. - it("labels the no-identifier row distinctly from an unresolved one", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a" }), request({ turn_id: "b", person_id: "a-stranger" })], - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - const rows = artefact - .split("\n") - .filter((line) => line.includes("nobody opted in") || line.includes("unresolved")); - - expect(rows).toHaveLength(2); - const [unresolvedRow] = rows.filter((line) => line.includes("unresolved")); - const [noneRow] = rows.filter((line) => line.includes("nobody opted in")); - // The two labels must never be interchangeable: neither row's label is a substring of - // the other's, so a reader can never mistake one bucket for the other. - expect(unresolvedRow).not.toContain("nobody opted in"); - expect(noneRow).not.toContain("unresolved"); - }); - - it("prints every figure and a caveat when the identity could not be read", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", cost_usd: 1, person_id: "machine-1" })], - identityUnusableCause: "unreadable", - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("$1.00"); - expect(artefact).toMatch(/own identity could not be read/u); - }); - - it("prints every figure and a different caveat when no identity was declared at all", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", cost_usd: 1, person_id: "machine-1" })], - identityUnusableCause: "absent", - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("$1.00"); - expect(artefact).toMatch(/no identity was declared/u); - }); - - it("prints no person caveat on the total axis when nobody opted in - that is the default state, not a degraded read", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", cost_usd: 1 })], - identityUnusableCause: "absent", - }); - - const artefact = buildCostReportArtefact(envelope, "total"); - - expect(artefact).toContain("$1.00"); - expect(artefact).not.toMatch(/no identity was declared/u); - }); - - it("still prints the unreadable caveat on the total axis - that one is real damage", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", cost_usd: 1 })], - identityUnusableCause: "unreadable", - }); - - const artefact = buildCostReportArtefact(envelope, "total"); - - expect(artefact).toMatch(/own identity could not be read/u); - }); - - it("names two different causes with two different caveats", () => { - const unreadable = buildCostReportArtefact( - envelopeOf({ identityUnusableCause: "unreadable" }), - "person" - ); - const absent = buildCostReportArtefact( - envelopeOf({ identityUnusableCause: "absent" }), - "person" - ); - - expect(unreadable).not.toBe(absent); - expect(unreadable).toMatch(/could not be read/u); - expect(absent).toMatch(/no identity was declared/u); - }); -}); - -// `by_step` is keyed on step plus attribution, so one skill can hold two rows sharing a name. -// A pasted table is the one place that column can be dropped silently. -describe("buildCostReportArtefact — by step, two rows sharing one name", () => { - const STEP = "aidd-dev:02-implement"; - - function ambiguousStepInput(): CostReportInput { - return { - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [ - request({ - turn_id: "a", - step_attribution: "tool-stated", - step: STEP, - input_tokens: 1000, - }), - request({ - turn_id: "b", - step_attribution: "journal-interval", - step: STEP, - input_tokens: 500, - }), - ], - journals: [], - declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }; - } - - it("carries the attribution on every row, so two rows for one step are distinguishable on their own", () => { - const report = buildCostReport(ambiguousStepInput()); - const artefact = buildCostReportArtefact(toCostReportEnvelope(report), "step"); - - const stepLines = artefact.split("\n").filter((line) => line.startsWith(`| ${STEP} |`)); - expect(stepLines).toHaveLength(2); - expect(stepLines.some((line) => line.includes("stated by the tool"))).toBe(true); - expect(stepLines.some((line) => line.includes("from a journal interval"))).toBe(true); - }); - - it("reconciles to what the terminal prints for that step, row for row", () => { - const report = buildCostReport(ambiguousStepInput()); - const artefact = buildCostReportArtefact(toCostReportEnvelope(report), "step"); - const output = new CapturingOutput(); - printCostReport(output, report); - const terminalText = output.lines.join("\n"); - - // Both renderings read the same `bySteps` data; the true total for the step (never - // itself printed as one line, by either renderer) is what a reader sums the rows to. - expect(terminalText).toContain(STEP); - expect(terminalText).toMatch(/stated by the tool/u); - expect(terminalText).toMatch(/from a journal interval/u); - - const toolStatedRow = artefact - .split("\n") - .find((line) => line.startsWith(`| ${STEP} |`) && line.includes("stated by the tool")); - const journalIntervalRow = artefact - .split("\n") - .find((line) => line.startsWith(`| ${STEP} |`) && line.includes("journal interval")); - expect(toolStatedRow).toContain("1,000 tokens"); - expect(journalIntervalRow).toContain("500 tokens"); - // 1,500 total input tokens across the two records - never printed as one row by either - // renderer, but recoverable from the two rows a reader is given. - }); -}); - -describe("buildCostReportArtefact — the agent axis names which silence a row is", () => { - const NAMES_AGENTS = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: true }, - export: null, - journalAttributable: false, - taskAttributable: false, - } as const; - - // Two rows carry no agent name and mean opposite things: printing "the main thread" for a - // tool that never names an agent states, of that tool, a fact nothing observed. - it("prints the main thread and a tool that names no agent as different rows", () => { - const envelope = envelopeOf({ - declaredTools: [ - { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - ], - records: [ - request({ input_tokens: 10 }), - request({ tool: "codex", vendor_id: "s-codex", input_tokens: 10 }), - ], - }); - - const artefact = buildCostReportArtefact(envelope, "agent"); - - expect(artefact).toContain("| the main thread |"); - expect(artefact).toContain("| the tool names no agent |"); - }); - - it("prints the same two labels in the terminal rendering", () => { - const report = buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - journals: [], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - declaredTools: [ - { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - ], - records: [ - request({ input_tokens: 10 }), - request({ tool: "codex", vendor_id: "s-codex", input_tokens: 10 }), - ], - }); - const output = new CapturingOutput(); - - printCostReport(output, report); - - const printed = output.lines.join("\n"); - expect(printed).toContain("the main thread"); - expect(printed).toContain("the tool names no agent"); - }); -}); - -describe("buildCostReportArtefact — the flow axis states its own limits with the figures", () => { - const FLOW_JOURNAL = [ - { - vendorId: "s-1", - tool: "claude-code" as const, - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary" as const, - }, - ], - }, - ]; - - function withOneFlow() { - return envelopeOf({ - records: [request({ event_timestamp: "2026-08-17T10:30:00Z", input_tokens: 10 })], - journals: FLOW_JOURNAL, - }); - } - - it("says a hand-run skill counts inside the flow it ran during", () => { - expect(buildCostReportArtefact(withOneFlow(), "flow")).toContain( - "a skill run by hand while a flow was open is counted inside it" - ); - }); - - it("says a same-named skill of the reader's own project opens a flow of its own", () => { - expect(buildCostReportArtefact(withOneFlow(), "flow")).toContain("opens a flow of its own"); - }); - - // The names are read from the declared set, never written out beside it: a hardcoded list - // would go on printing its own three once a fourth orchestrator is declared. - it("names every unqualified orchestrating skill the declared set holds, whatever it holds", () => { - const artefact = buildCostReportArtefact(withOneFlow(), "flow"); - const bare = bareOrchestratingSkillNames(); - - expect(bare.length).toBeGreaterThan(0); - for (const name of bare) expect(artefact).toContain(name); - }); - - it("says neither when the period names no flow at all - a limit that bit nothing is noise", () => { - const noFlow = envelopeOf({ - records: [request({ event_timestamp: "2026-08-17T10:30:00Z", input_tokens: 10 })], - journals: [], - }); - const artefact = buildCostReportArtefact(noFlow, "flow"); - expect(artefact).not.toContain("counted inside it"); - expect(artefact).not.toContain("opens a flow of its own"); - }); - - // A limit is a statement about a mechanism that ran, and a period whose only flow its own - // tool named never walked a step sequence. - it("states no journal limit for a period whose only flow its own tool named", () => { - const statedOnly = envelopeOf({ - records: [ - request({ - event_timestamp: "2026-08-17T10:30:00Z", - input_tokens: 10, - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - }), - ], - journals: [], - }); - - const artefact = buildCostReportArtefact(statedOnly, "flow"); - - expect(artefact).toContain("is every run of that skill at once"); - expect(artefact).not.toContain("counted inside it"); - expect(artefact).not.toContain("opens a flow of its own"); - }); - - it("states no tool-stated limit for a period whose flows the journal all witnessed", () => { - expect(buildCostReportArtefact(withOneFlow(), "flow")).not.toContain( - "is every run of that skill at once" - ); - }); - - it("states them on the flow axis alone, never on every axis", () => { - const envelope = withOneFlow(); - for (const axis of ARTEFACT_AXES.filter((name) => name !== "flow")) { - expect(buildCostReportArtefact(envelope, axis)).not.toContain("counted inside it"); - } - }); -}); - -const THREE_TOOLS = [ - { - tool: "claude", - coverage: "covered", - capability: { - localRead: { tokenCounters: true, amount: true, toolStatedStep: true, agentName: true }, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - { - tool: "cursor", - coverage: "not-covered", - reason: "It writes no token count.", - capability: NO_CAPABILITY, - }, -] as const satisfies CostReportInput["declaredTools"]; - -function threeDayInput(overrides: Partial = {}): CostReportInput { - return { - fromDay: "2026-08-17", - toDay: "2026-08-19", - declaredTools: [...THREE_TOOLS], - records: [], - journals: [], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }; -} - -function threeDayRich(overrides: Partial = {}): CostReportInput { - return threeDayInput({ - records: [ - request({ - turn_id: "a", - prompt_id: "p-1", - cost_usd: 6, - input_tokens: 600, - model: "opus", - project_id: "acme/widgets", - person_id: "machine-1", - step: "aidd-dev:02-implement", - step_attribution: "tool-stated", - event_timestamp: "2026-08-17T10:00:00Z", - }), - request({ - turn_id: "b", - cost_usd: 3, - input_tokens: 300, - person_id: "a-stranger", - step: "aidd-dev:02-implement", - step_attribution: "journal-interval", - event_timestamp: "2026-08-18T10:00:00Z", - }), - request({ - turn_id: "c", - cost_usd: 1, - input_tokens: 100, - event_timestamp: "2026-08-18T11:00:00Z", - }), - ], - journals: [ - { - vendorId: "s-1", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_17_reporting/plan.md"], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T09:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary", - }, - ], - }, - ], - identity: onePersonMapping(), - undatedRecords: 3, - unreadableLines: 2, - ...overrides, - }); -} - -function axisLines(from: CostReportInput, axis: string): string[] { - return buildCostReportArtefact(toCostReportEnvelope(buildCostReport(from)), axis).split("\n"); -} - -const RICH_CAVEATS = [ - "3 records carry no moment and are in no period", - "2 lines could not be read", -]; - -describe("buildCostReportArtefact — one period, every axis rendered whole", () => { - it("answers the total axis with the period, the one figure and what the read could not do", () => { - expect(axisLines(threeDayRich(), "total")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: total", - "", - "$10.00 — 1,000 tokens, 3 requests", - ...RICH_CAVEATS, - ]); - }); - - it("gives the day axis a row per day the period spans, the empty one included", () => { - expect(axisLines(threeDayRich(), "day")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by day", - "", - "| Day | Total |", - "| --- | --- |", - "| 2026-08-17 | $6.00 — 600 tokens, 1 requests |", - "| 2026-08-18 | $4.00 — 400 tokens, 2 requests |", - "| 2026-08-19 | nothing in this period |", - ...RICH_CAVEATS, - ]); - }); - - it("keeps two rows sharing one step name apart by their attribution column", () => { - expect(axisLines(threeDayRich(), "step")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by step", - "", - "| Step | Attribution | Total |", - "| --- | --- | --- |", - "| aidd-dev:02-implement | stated by the tool | $6.00 — 600 tokens, 1 requests |", - "| aidd-dev:02-implement | from a journal interval | $3.00 — 300 tokens, 1 requests |", - "| unattributed | unattributed | $1.00 — 100 tokens, 1 requests |", - ...RICH_CAVEATS, - ]); - }); - - it("names the model axis's unnamed row rather than dropping it", () => { - expect(axisLines(threeDayRich(), "model")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by model", - "", - "| Model | Total |", - "| --- | --- |", - "| opus | $6.00 — 600 tokens, 1 requests |", - "| no known model | $4.00 — 400 tokens, 2 requests |", - ...RICH_CAVEATS, - ]); - }); - - it("calls the agent axis's own row the main thread when the tool names agents", () => { - expect(axisLines(threeDayRich(), "agent")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by agent", - "", - "| Agent | Total |", - "| --- | --- |", - "| the main thread | $10.00 — 1,000 tokens, 3 requests |", - ...RICH_CAVEATS, - ]); - }); - - it("dates a named prompt row and em-dashes the one drawn from many turns", () => { - expect(axisLines(threeDayRich(), "prompt")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by prompt", - "", - "| Prompt | Started at | Total |", - "| --- | --- | --- |", - "| p-1 | 2026-08-17T10:00:00Z | $6.00 — 600 tokens, 1 requests |", - "| no prompt named | — | $4.00 — 400 tokens, 2 requests |", - ...RICH_CAVEATS, - ]); - }); - - it("gives an unattributed task row its reason and no attribution", () => { - expect(axisLines(threeDayRich(), "task")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by task", - "", - "| Task | Attribution | Total |", - "| --- | --- | --- |", - "| no usable task declaration in this session | — | $10.00 — 1,000 tokens, 3 requests |", - ...RICH_CAVEATS, - ]); - }); - - it("gives the backlog axis two columns, never the task axis's third", () => { - expect(axisLines(threeDayRich(), "backlog")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by backlog", - "", - "| Backlog item | Total |", - "| --- | --- |", - "| no usable task declaration in this session | $10.00 — 1,000 tokens, 3 requests |", - ...RICH_CAVEATS, - ]); - }); - - it("carries the flow axis's two journal limits under its rows", () => { - expect(axisLines(threeDayRich(), "flow")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by flow", - "", - "| Flow | Attribution | Opened at | Total |", - "| --- | --- | --- | --- |", - "| aidd-orchestrator:01-sdlc | from a journal interval | 2026-08-17T09:00:00Z | $6.00 — 600 tokens, 1 requests |", - "| outside any flow | unattributed | — | $4.00 — 400 tokens, 2 requests |", - "a skill run by hand while a flow was open is counted inside it: the orchestrator's own call and a person's write the identical step_start line", - "a skill of this project named 00-async-dev, 01-sdlc or 02-backlog opens a flow of its own: outside a plugin a host names a skill by its folder alone, and this axis has only that name to go on", - ...RICH_CAVEATS, - ]); - }); - - it("gives every declared tool a row, the unread one included", () => { - expect(axisLines(threeDayRich(), "tool")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by tool", - "", - "| Tool | Total |", - "| --- | --- |", - "| Claude Code | $10.00 — 1,000 tokens, 3 requests |", - "| Codex | nothing in this period |", - "| Cursor | not covered — It writes no token count. |", - ...RICH_CAVEATS, - ]); - }); - - it("names the project axis's unnamed row rather than dropping it", () => { - expect(axisLines(threeDayRich(), "project")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by project", - "", - "| Project | Total |", - "| --- | --- |", - "| acme/widgets | $6.00 — 600 tokens, 1 requests |", - "| no known project | $4.00 — 400 tokens, 2 requests |", - ...RICH_CAVEATS, - ]); - }); - - it("carries the identities behind every person row as that row's own evidence", () => { - expect(axisLines(threeDayRich(), "person")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by person", - "", - "| Person | Identities | Total |", - "| --- | --- | --- |", - "| Ada | person-a, machine-1 | $6.00 — 600 tokens, 1 requests |", - "| Ada | person-a, machine-1 | $1.00 — 100 tokens, 1 requests |", - "| unresolved — not mapped to anyone (a-stranger) | a-stranger | $3.00 — 300 tokens, 1 requests |", - ...RICH_CAVEATS, - ]); - }); -}); - -describe("buildCostReportArtefact — what the header carries", () => { - it("appends the switch being off to the axis it names, in full", () => { - expect(axisLines(threeDayRich({ measurementEnabled: false }), "total")[0]).toBe( - "period 2026-08-17 to 2026-08-19 — axis: total — this project's switch is off, figures are the whole sink, not scoped to it" - ); - }); - - it("names the task and every filter that narrowed the period, before the axis", () => { - expect( - axisLines( - threeDayRich({ - task: "2026_08/2026_08_17_reporting", - filters: { project: "acme/widgets" }, - }), - "total" - )[0] - ).toBe( - "period 2026-08-17 to 2026-08-19, task 2026_08/2026_08_17_reporting, filters: project=acme/widgets — axis: total" - ); - }); - - it("names the filter that emptied a selection, with why its value was never seen", () => { - expect( - axisLines( - threeDayInput({ - records: [request({ turn_id: "a", cost_usd: 1 })], - filters: { project: "never-worked-here" }, - }), - "total" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19, filters: project=never-worked-here — axis: total", - "", - "nothing in this selection", - "project 'never-worked-here' matched nothing — no record has ever named this project", - ]); - }); -}); - -describe("buildCostReportArtefact — a figure a row cannot state", () => { - it("prints an empty period's one figure as nothing measured, never as a zero", () => { - expect(axisLines(threeDayInput(), "total")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: total", - "", - "nothing in this period", - ]); - }); - - it("prints an unknown amount beside the tokens a tool did count", () => { - expect( - axisLines( - threeDayInput({ records: [request({ tool: "codex", input_tokens: 8898 })] }), - "tool" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by tool", - "", - "| Tool | Total |", - "| --- | --- |", - "| Claude Code | nothing in this period |", - "| Codex | amount unknown — 8,898 tokens, 1 requests |", - "| Cursor | not covered — It writes no token count. |", - ]); - }); - - it("prints a session total on its own tool row, never as nothing in this period", () => { - expect( - axisLines( - { - fromDay: "2026-08-17", - toDay: "2026-08-17", - declaredTools: [ - { - tool: "copilot", - coverage: "covered", - capability: { - localRead: { - tokenCounters: true, - amount: false, - toolStatedStep: false, - agentName: false, - }, - export: null, - journalAttributable: true, - taskAttributable: false, - }, - }, - ], - records: [ - request({ - tool: "copilot", - kind: "session", - input_tokens: 10, - output_tokens: 42, - cache_creation_tokens: 21070, - }), - ], - journals: [], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }, - "tool" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-17 — axis: by tool", - "", - "| Tool | Total |", - "| --- | --- |", - "| GitHub Copilot | 21,122 tokens (session total, not requests) |", - ]); - }); - - it("tells a tool that named no agent from the main thread, row for row", () => { - expect( - axisLines( - threeDayInput({ - declaredTools: [THREE_TOOLS[0], THREE_TOOLS[1]], - records: [ - request({ turn_id: "a", input_tokens: 10 }), - request({ turn_id: "b", tool: "codex", vendor_id: "s-codex", input_tokens: 10 }), - ], - }), - "agent" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by agent", - "", - "| Agent | Total |", - "| --- | --- |", - "| the main thread | amount unknown — 10 tokens, 1 requests |", - "| the tool names no agent | amount unknown — 10 tokens, 1 requests |", - ]); - }); - - it("em-dashes the identities column of the row nobody opted into", () => { - expect( - axisLines( - threeDayInput({ - records: [request({ turn_id: "a", cost_usd: 1 })], - identityUnusableCause: "absent", - }), - "person" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by person", - "", - "| Person | Identities | Total |", - "| --- | --- | --- |", - "| no identity — nobody opted in | — | $1.00 — 0 tokens, 1 requests |", - "no identity was declared; every identifier is reported unresolved", - ]); - }); - - it("reports every identifier unresolved when this machine's identity could not be read", () => { - expect( - axisLines( - threeDayInput({ - records: [request({ turn_id: "a", cost_usd: 1, person_id: "machine-1" })], - identityUnusableCause: "unreadable", - }), - "person" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by person", - "", - "| Person | Identities | Total |", - "| --- | --- | --- |", - "| unresolved — not mapped to anyone (machine-1) | machine-1 | $1.00 — 0 tokens, 1 requests |", - "this machine's own identity could not be read; every identifier is reported unresolved", - ]); - }); - - it("em-dashes the opening moment of a flow only a tool's own record named, and says why", () => { - expect( - axisLines( - threeDayInput({ - records: [ - request({ - turn_id: "a", - input_tokens: 10, - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - event_timestamp: "2026-08-17T10:00:00Z", - }), - ], - }), - "flow" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by flow", - "", - "| Flow | Attribution | Opened at | Total |", - "| --- | --- | --- | --- |", - "| aidd-orchestrator:01-sdlc | stated by the tool | — | amount unknown — 10 tokens, 1 requests |", - "a flow only a record's own tool named is every run of that skill at once: its journal opened no flow to bound one run from the next, so the row has no opening moment and its total is not one orchestration's", - ]); - }); -}); - -describe("buildCostReportArtefact — a task the journal declared", () => { - const DECLARED_TASK = threeDayInput({ - records: [ - request({ turn_id: "a", cost_usd: 2, event_timestamp: "2026-08-17T10:00:00Z" }), - request({ turn_id: "b", cost_usd: 1, event_timestamp: "2026-08-18T10:00:00Z" }), - ], - journals: [ - { - vendorId: "s-1", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [ - { - path: "aidd_docs/tasks/2026_08/2026_08_17_reporting/plan.md", - startMs: Date.parse("2026-08-17T09:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - }, - ], - flowIntervals: [], - }, - ], - }); - - it("says a named task row rests on a declaration, and gives the rest its reason", () => { - expect(axisLines(DECLARED_TASK, "task")).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by task", - "", - "| Task | Attribution | Total |", - "| --- | --- | --- |", - "| 2026_08/2026_08_17_reporting | declared by the flow | $2.00 — 0 tokens, 1 requests |", - "| the journal falls silent before this record | — | $1.00 — 0 tokens, 1 requests |", - ]); - }); - - it("names a task whose backlog declaration could not be read, never leaving the row blank", () => { - expect( - axisLines( - { - ...DECLARED_TASK, - taskBacklogDeclarations: new Map([ - ["2026_08/2026_08_17_reporting", { kind: "unreadable" as const }], - ]), - }, - "backlog" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by backlog", - "", - "| Backlog item | Total |", - "| --- | --- |", - "| this task's backlog declaration could not be read | $2.00 — 0 tokens, 1 requests |", - "| the journal falls silent before this record | $1.00 — 0 tokens, 1 requests |", - ]); - }); -}); - -describe("buildCostReportArtefact — a fact a row carries beside its figure", () => { - it("keeps a covered tool's own reason after the figure it qualifies", () => { - expect( - axisLines( - threeDayInput({ - declaredTools: [ - THREE_TOOLS[0], - { - tool: "codex", - coverage: "covered", - capability: NO_CAPABILITY, - reason: "Partial read.", - }, - ], - records: [request({ turn_id: "a", cost_usd: 1, tool: "codex" })], - }), - "tool" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19 — axis: by tool", - "", - "| Tool | Total |", - "| --- | --- |", - "| Claude Code | nothing in this period |", - "| Codex | $1.00 — 0 tokens, 1 requests — Partial read. |", - ]); - }); - - it("sums all four token counters into a row's own figure", () => { - expect( - axisLines( - threeDayInput({ - records: [ - request({ - turn_id: "a", - cost_usd: 4, - input_tokens: 10, - output_tokens: 20, - cache_read_tokens: 30, - cache_creation_tokens: 40, - }), - ], - }), - "total" - )[2] - ).toBe("$4.00 — 100 tokens, 1 requests"); - }); - - it("names an agent a record's own tool stated, above the main thread's own row", () => { - expect( - axisLines( - threeDayInput({ - records: [ - request({ turn_id: "a", cost_usd: 4, input_tokens: 100, agent_name: "reviewer" }), - request({ turn_id: "b", cost_usd: 1, input_tokens: 5 }), - ], - }), - "agent" - ).slice(4) - ).toEqual([ - "| reviewer | $4.00 — 100 tokens, 1 requests |", - "| the main thread | $1.00 — 5 tokens, 1 requests |", - ]); - }); - - it("says a filter emptied a selection only in combination with the rest of it", () => { - expect( - axisLines( - threeDayInput({ - records: [ - request({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets", model: "opus" }), - request({ turn_id: "b", cost_usd: 1, project_id: "acme/gadgets", model: "haiku" }), - ], - knownValues: { - projects: new Set(["acme/widgets"]), - steps: new Set(), - models: new Set(["haiku"]), - }, - filters: { project: "acme/widgets", model: "haiku" }, - }), - "total" - ) - ).toEqual([ - "period 2026-08-17 to 2026-08-19, filters: project=acme/widgets, model=haiku — axis: total", - "", - "nothing in this selection", - "model 'haiku' matched nothing combined with the rest of this selection", - ]); - }); -}); diff --git a/cli/tests/presentation/display/cost-report-display.unit.test.ts b/cli/tests/presentation/display/cost-report-display.unit.test.ts deleted file mode 100644 index 6e66f6e21..000000000 --- a/cli/tests/presentation/display/cost-report-display.unit.test.ts +++ /dev/null @@ -1,988 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import { - buildCostReport, - type CostReportInput, -} from "../../../src/contexts/telemetry/domain/cost-report.js"; -import type { TelemetrySinkRecord } from "../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; -import { padTo, printCostReport } from "../../../src/presentation/display/cost-report-display.js"; -import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; - -function record(overrides: Partial): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - ...overrides, - }; -} - -/** What a tool can supply is not what these tests are about: the minimum the type requires, - * whose own truth is checked against captured files elsewhere. */ -const NO_CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -function printed(overrides: Partial = {}): string { - const output = new CapturingOutput(); - printCostReport( - output, - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [ - { tool: "claude", coverage: "covered", capability: NO_CAPABILITY }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - { - tool: "cursor", - coverage: "not-covered", - reason: "It writes no token count.", - capability: NO_CAPABILITY, - }, - ], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }) - ); - return output.lines.join("\n"); -} - -describe("printCostReport", () => { - it("answers the question before any breakdown is read", () => { - const out = printed({ - records: [record({ cost_usd: 4.2, input_tokens: 100, cache_read_tokens: 900 })], - }); - const [first, , sessions, requests, tokens, cost] = out.split("\n"); - - expect(first).toContain("2026-08-17 to 2026-08-21"); - expect(sessions).toContain("sessions"); - expect(requests).toContain("requests"); - expect(tokens).toContain("1,000"); - expect(tokens).toContain("90% cache"); - expect(cost).toContain("$4.20"); - }); - - it("says which selection it answered, in the header", () => { - const out = printed({ - records: [record({ cost_usd: 1, project_id: "acme/widgets" })], - filters: { project: "acme/widgets" }, - }); - - expect(out.split("\n")[0]).toContain("filters: project=acme/widgets"); - }); - - it("names the filter that emptied a selection, and suppresses the noise under it", () => { - const out = printed({ - records: [record({ cost_usd: 1, project_id: "acme/widgets" })], - knownValues: { projects: new Set(["acme/widgets"]), steps: new Set(), models: new Set() }, - filters: { project: "never-worked-here" }, - }); - - expect(out).toContain("no record has ever named this project"); - expect(out).not.toContain("by tool"); - expect(out).not.toContain("by day"); - }); - - it("says a task or a tool was never seen without claiming a record check it never ran", () => { - const out = printed({ - records: [record({ cost_usd: 1 })], - filters: { tool: "opencode" }, - }); - - expect(out).toContain("it is not one of the tools this build knows"); - expect(out).not.toContain("no record has ever named this tool"); - }); - - it("calls a zero row 'nothing in this selection', never 'this period', once a filter is active", () => { - // Codex only has a gadgets record - filtered to widgets alone, its row is zero, but - // the selection is why, not real idleness. - const out = printed({ - records: [ - record({ turn_id: "a", cost_usd: 1, tool: "claude", project_id: "acme/widgets" }), - record({ turn_id: "b", cost_usd: 1, tool: "codex", project_id: "acme/gadgets" }), - ], - filters: { project: "acme/widgets" }, - }); - - expect(out).toMatch(/Codex\s+nothing in this selection/u); - expect(out).not.toContain("nothing in this period"); - }); - - it("still calls a zero row 'nothing in this period' when the whole period, not a filter, is why", () => { - const out = printed({ records: [] }); - - expect(out).toContain("nothing in this period"); - expect(out).not.toContain("nothing in this selection"); - }); - - it("calls a task selection's own zero rows 'nothing in this selection' too", () => { - const out = printed({ - records: [record({ vendor_id: "s-1", cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" })], - journals: [ - { - vendorId: "s-1", - tool: "claude", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_01_x/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ], - task: "2026_08/2026_08_01_x", - }); - - expect(out).toMatch(/2026-08-18\s+nothing in this selection/u); - }); - - it("labels active time as per-session and keeps it out of every breakdown", () => { - const out = printed({ - records: [ - record({ cost_usd: 1, step: "aidd-dev:02-implement", step_attribution: "tool-stated" }), - record({ kind: "session", active_time_s: 2820 }), - ], - }); - - expect(out).toContain("47 min"); - expect(out).toContain("not attributable to steps"); - const breakdown = out.slice(out.indexOf("by step")); - expect(breakdown).not.toContain("min"); - }); - - it("prints the three attribution shares together", () => { - const out = printed({ - records: [ - record({ turn_id: "a", cost_usd: 6, step: "s", step_attribution: "tool-stated" }), - record({ turn_id: "b", cost_usd: 3, step: "s", step_attribution: "journal-interval" }), - record({ turn_id: "c", cost_usd: 1 }), - ], - }); - const mix = out.slice(out.indexOf("attribution ")); - - expect(mix).toContain("stated by the tool"); - expect(mix).toContain("from a journal interval"); - expect(mix).toContain("unattributed"); - expect(mix).toContain(" 60%"); - expect(mix).toContain(" 30%"); - expect(mix).toContain(" 10%"); - }); - - it("never says work ran outside every step, and never calls it a residual", () => { - const out = printed({ records: [record({ cost_usd: 1 })] }); - - expect(out).toContain("unattributed"); - expect(out).not.toContain("residual"); - expect(out).not.toContain("no step"); - expect(out).not.toContain("outside"); - }); - - it("prints an unknown amount for a tool whose records carry none, never a zero", () => { - const out = printed({ records: [record({ tool: "codex", input_tokens: 8898 })] }); - - expect(out).toContain("amount unknown"); - expect(out).not.toContain("$0.00"); - }); - - it("prints a tool that cannot be read as not covered, with its own reason", () => { - const out = printed({ records: [record({ cost_usd: 1 })] }); - - expect(out).toContain("Cursor"); - expect(out).toContain("not covered — It writes no token count."); - }); - - it("prints a session total on its own tool row, not 'nothing in this period' (#697)", () => { - const output = new CapturingOutput(); - const COPILOT_CAPABILITY = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - export: { tokenCounters: false, amount: false, toolStatedStep: false, agentName: false }, - journalAttributable: true, - taskAttributable: false, - } as const; - printCostReport( - output, - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [ - record({ - tool: "copilot", - kind: "session", - provenance: "local-read", - input_tokens: 10, - output_tokens: 42, - cache_read_tokens: 0, - cache_creation_tokens: 21070, - }), - ], - journals: [], - declaredTools: [{ tool: "copilot", coverage: "covered", capability: COPILOT_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }) - ); - const out = output.lines.join("\n"); - - expect(out).toContain("21,122 tokens (session total, not requests)"); - const copilotRow = out.split("\n").find((line) => line.includes("Copilot")) ?? ""; - expect(copilotRow).not.toContain("nothing in this period"); - }); - - it("separates a tool that measured nothing from one that could not be read", () => { - const out = printed({ records: [record({ cost_usd: 1 })] }); - const codexRow = out.split("\n").find((line) => line.includes("Codex")) ?? ""; - const cursorRow = out.split("\n").find((line) => line.includes("Cursor")) ?? ""; - - expect(codexRow).toContain("nothing in this period"); - expect(cursorRow).toContain("not covered"); - expect(codexRow).not.toContain("not covered"); - }); - - it("prints an empty period as nothing measured, not as zeros", () => { - const out = printed(); - - expect(out).toContain("nothing in this period"); - expect(out).not.toContain("$0.00"); - expect(out).not.toContain("by step"); - }); - - it("says how much of the read it could not place or could not parse", () => { - const out = printed({ undatedRecords: 3, unreadableLines: 2 }); - - expect(out).toContain("3 records carry no moment and are in no period"); - expect(out).toContain("2 lines could not be read"); - }); - - it("breaks a period down by tokens when no amount exists anywhere in it", () => { - const out = printed({ - records: [record({ tool: "codex", model: "gpt-5.6-sol", input_tokens: 10 })], - }); - - expect(out).toContain("of tokens"); - expect(out).not.toContain("of cost"); - }); - - it("names a task by its identity, never by a path it was derived from", () => { - const out = printed({ - records: [record({ vendor_id: "s-1", cost_usd: 1 })], - journals: [ - { - vendorId: "s-1", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ], - task: "2026_08/2026_08_21_cost-reporter", - }); - - expect(out).toContain("task 2026_08/2026_08_21_cost-reporter"); - expect(out).not.toContain("aidd_docs/"); - expect(out).not.toContain("plan.md"); - }); - - it("carries no prompt, code or diff, over records and journals that hold them", () => { - const out = printed({ - records: [ - record({ - cost_usd: 1, - model: "opus", - step: "aidd-dev:02-implement", - step_attribution: "tool-stated", - }), - ], - journals: [ - { - vendorId: "s-1", - tool: "claude-code", - projectId: "acme-widgets", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ], - }); - - // Named rather than "no slash at all": a task's identity legitimately carries one, and - // an assertion that broke on it would say nothing about a leaked path. - expect(out).not.toContain("aidd_docs"); - expect(out).not.toContain(".md"); - expect(out).not.toContain("acme-widgets"); - }); - - it("prints a day with nothing as a row of zeros, never an omitted row", () => { - const out = printed({ - records: [record({ cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" })], - }); - - expect(out).toMatch(/2026-08-18\s+nothing in this period/u); - }); - - it("names how many days a long period carries, rather than printing every row", () => { - const records = Array.from({ length: 40 }, (_, i) => - record({ - turn_id: `t-${i}`, - cost_usd: 1, - event_timestamp: `2026-01-${String((i % 27) + 1).padStart(2, "0")}T00:00:00Z`, - }) - ); - const out = printed({ fromDay: "2026-01-01", toDay: "2026-02-09", records }); - - expect(out).toContain("40 days in this period"); - expect(out).toContain("--json"); - expect(out).not.toContain("2026-01-15"); - }); - - it("prints the prompt that caused the work, dated, largest first", () => { - const out = printed({ - records: [ - record({ - turn_id: "a", - prompt_id: "p-1", - cost_usd: 2, - event_timestamp: "2026-08-18T09:00:00Z", - }), - record({ - turn_id: "b", - prompt_id: "p-2", - cost_usd: 1, - event_timestamp: "2026-08-18T10:00:00Z", - }), - ], - }); - - expect(out).toContain("by prompt"); - const prompts = out.split("\n").filter((line) => line.includes("p-1") || line.includes("p-2")); - expect(prompts[0]).toContain("p-1"); - expect(prompts[0]).toContain("2026-08-18T09:00:00Z"); - }); - - // The first axis whose cardinality is unbounded, so it truncates rather than suppressing - // every row: a partial series is a lie about continuity, a top N of a ranking is not. - it("names how many prompts a long period carries beyond the ones it prints", () => { - const records = Array.from({ length: 30 }, (_, i) => - record({ turn_id: `t-${i}`, prompt_id: `p-${i}`, cost_usd: 30 - i }) - ); - const out = printed({ records }); - - expect(out).toContain("p-0"); - expect(out).not.toContain("p-29"); - expect(out).toContain("20 more prompts"); - expect(out).toContain("--json"); - }); - - it("gives a record with no project its own row, named as unknown", () => { - const out = printed({ - records: [ - record({ turn_id: "a", cost_usd: 2, project_id: "acme/widgets" }), - record({ turn_id: "b", cost_usd: 1 }), - ], - }); - const projects = out.slice(out.indexOf("by project")); - - expect(projects).toContain("acme/widgets"); - expect(projects).toContain("no known project"); - }); - - it("gives a record with no model its own row, named as unknown, rather than vanishing", () => { - const out = printed({ - records: [ - record({ turn_id: "a", cost_usd: 2, model: "opus" }), - record({ turn_id: "b", cost_usd: 1 }), - ], - }); - const models = out.slice(out.indexOf("by model"), out.indexOf("by project")); - - expect(models).toContain("opus"); - expect(models).toContain("no known model"); - }); -}); - -describe("printCostReport — a label wider than its column", () => { - // Measured on a real report: a project id can be a remote 41 characters wide against a - // 26-wide column, and `padEnd` returns a longer string unchanged. - it("keeps a separator between an overlong label and its share", () => { - const long = "git@github.com:ai-driven-dev/framework.git"; - - const out = printed({ records: [record({ cost_usd: 1, project_id: long })] }); - const row = out.split("\n").find((line) => line.includes(long)); - - expect(row).toBeDefined(); - expect(row).not.toContain(`${long}100%`); - expect(row).toMatch(new RegExp(`${long.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\s`, "u")); - }); - - it("still separates a label exactly as wide as its column from what follows it", () => { - // 26 is `LABEL_WIDTH`, private to this module: the only length where `padTo`'s `>=` and - // `>` decide something different, and nothing above ever sits on that boundary. - const exact = "a".repeat(26); - - const padded = padTo(exact, 26); - - expect(padded).toBe(`${exact} `); - }); -}); - -describe("printCostReport — measurement is off", () => { - it("says the project's switch is off, on an empty period", () => { - const out = printed({ measurementEnabled: false }); - - expect(out).toMatch(/this project's own switch is off/u); - expect(out).not.toMatch(/\bsessions\s+0\b/u); - expect(out).toContain("nothing in this period"); - }); - - it("says nothing about the switch when it is on, even on an empty period", () => { - const out = printed({ measurementEnabled: true }); - - expect(out).not.toContain("switch is off"); - expect(out).not.toMatch(/\bsessions\s+0\b/u); - }); - - // The sink is person-scoped while the switch is project-scoped, so a genuine figure below - // an "off" claim is ordinary: the sentence must name its scope, not deny the figure. - it("names the sink's real scope, never denying the figure it sits beside", () => { - const out = printed({ - measurementEnabled: false, - records: [record({ cost_usd: 4.2, input_tokens: 100 })], - }); - - expect(out).toMatch(/this project's own switch is off/u); - expect(out).toMatch(/not scoped to it/u); - expect(out).toContain("$4.20"); - expect(out).toMatch(/\bcost\s+\$4\.20/u); - }); -}); - -const THREE_DAY_TOOLS: CostReportInput["declaredTools"] = [ - { - tool: "claude", - coverage: "covered", - capability: { - localRead: { tokenCounters: true, amount: true, toolStatedStep: true, agentName: true }, - export: null, - journalAttributable: true, - taskAttributable: true, - }, - }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - { - tool: "cursor", - coverage: "not-covered", - reason: "It writes no token count.", - capability: NO_CAPABILITY, - }, -]; - -function threeDay(overrides: Partial = {}): string[] { - const output = new CapturingOutput(); - printCostReport( - output, - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-19", - declaredTools: THREE_DAY_TOOLS, - records: [], - journals: [], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }) - ); - return output.lines; -} - -const THREE_TOOL_ROWS = [ - "", - " by tool", - " Claude Code nothing in this period", - " Codex nothing in this period", - " Cursor not covered — It writes no token count.", -]; - -describe("printCostReport — one period, rendered whole", () => { - it("prints every heading and every row of a period holding work", () => { - expect( - threeDay({ - records: [ - record({ - turn_id: "a", - prompt_id: "p-1", - cost_usd: 6, - input_tokens: 600, - model: "opus", - project_id: "acme/widgets", - step: "aidd-dev:02-implement", - step_attribution: "tool-stated", - event_timestamp: "2026-08-17T10:00:00Z", - }), - record({ - turn_id: "b", - cost_usd: 3, - input_tokens: 300, - step: "aidd-dev:02-implement", - step_attribution: "journal-interval", - event_timestamp: "2026-08-18T10:00:00Z", - }), - record({ - turn_id: "c", - cost_usd: 1, - input_tokens: 100, - event_timestamp: "2026-08-18T11:00:00Z", - }), - ], - undatedRecords: 3, - unreadableLines: 2, - }) - ).toEqual([ - "period 2026-08-17 to 2026-08-19", - "", - " sessions 1", - " requests 3", - " tokens 1,000 0% cache", - " cost $10.00", - "", - " by step of cost", - " aidd-dev:02-implement 60% $6.00 stated by the tool", - " aidd-dev:02-implement 30% $3.00 from a journal interval", - " unattributed 10% $1.00", - "", - " attribution of cost", - " stated by the tool 60%", - " matched on the prompt 0%", - " from a journal interval 30%", - " unattributed 10%", - "", - " by agent of cost", - " the main thread 100% $10.00", - "", - " by prompt of cost", - " p-1 2026-08-17T10:00:00Z 60% $6.00", - " no prompt named 40% $4.00", - "", - " by model of cost", - " opus 60% $6.00", - " no known model 40% $4.00", - "", - " by project of cost", - " acme/widgets 60% $6.00", - " no known project 40% $4.00", - "", - " by task of cost", - " no usable run journal for this session 100% $10.00", - "", - " by backlog item of cost", - " no usable run journal for this session 100% $10.00", - "", - " by tool", - " Claude Code $10.00 1,000 tokens", - " Codex nothing in this period", - " Cursor not covered — It writes no token count.", - "", - " by day", - " 2026-08-17 $6.00 600 tokens", - " 2026-08-18 $4.00 400 tokens", - " 2026-08-19 nothing in this period", - " 3 records carry no moment and are in no period", - " 2 lines could not be read", - ]); - }); - - it("prints an empty period as its two totals and its zero rows, under the off line", () => { - expect(threeDay({ measurementEnabled: false })).toEqual([ - "period 2026-08-17 to 2026-08-19", - "this project's own switch is off — the figures below are not scoped to it, they are the whole sink; turn this project's measurement on with `aidd telemetry on`", - "", - " sessions nothing in this period", - " requests nothing in this period", - ...THREE_TOOL_ROWS, - "", - " by day", - " 2026-08-17 nothing in this period", - " 2026-08-18 nothing in this period", - " 2026-08-19 nothing in this period", - ]); - }); - - it("breaks a period with no amount anywhere down by tokens, naming the basis on every heading", () => { - expect( - threeDay({ records: [record({ tool: "codex", model: "gpt-5", input_tokens: 10 })] }) - ).toEqual([ - "period 2026-08-17 to 2026-08-19", - "", - " sessions 1", - " requests 1", - " tokens 10 0% cache", - " cost amount unknown", - "", - " by step of tokens", - " unattributed 100% 10 tokens", - "", - " attribution of tokens", - " stated by the tool 0%", - " matched on the prompt 0%", - " from a journal interval 0%", - " unattributed 100%", - "", - " by agent of tokens", - " the tool names no agent 100% 10 tokens", - "", - " by prompt of tokens", - " no prompt named 100% 10 tokens", - "", - " by model of tokens", - " gpt-5 100% 10 tokens", - "", - " by project of tokens", - " no known project 100% 10 tokens", - "", - " by task of tokens", - " no usable run journal for this session 100% 10 tokens", - "", - " by backlog item of tokens", - " no usable run journal for this session 100% 10 tokens", - "", - " by tool", - " Claude Code nothing in this period", - " Codex amount unknown 10 tokens", - " Cursor not covered — It writes no token count.", - "", - " by day", - " 2026-08-17 nothing in this period", - " 2026-08-18 nothing in this period", - " 2026-08-19 nothing in this period", - ]); - }); -}); - -describe("printCostReport — a share with nothing to divide", () => { - it("prints a dash in place of every share when the basis is zero", () => { - const lines = threeDay({ records: [record({ turn_id: "a", cost_usd: 0 })] }); - - expect(lines).toContain(" unattributed - $0.00"); - expect(lines).toContain(" stated by the tool - "); - expect(lines).toContain(" the main thread - $0.00"); - }); -}); - -describe("printCostReport — the lines a period only sometimes carries", () => { - it("prints active time as its own row, after the cost", () => { - const lines = threeDay({ - records: [ - record({ turn_id: "a", cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" }), - record({ kind: "session", active_time_s: 2820 }), - ], - }); - - expect(lines.slice(0, 7)).toEqual([ - "period 2026-08-17 to 2026-08-19", - "", - " sessions 1", - " requests 1", - " tokens 0 0% cache", - " cost $1.00", - " active time 47 min per session; not attributable to steps", - ]); - }); - - it("names how many prompts it withheld, under the ten it printed", () => { - const lines = threeDay({ - records: Array.from({ length: 12 }, (_, i) => - record({ turn_id: `t-${i}`, prompt_id: `p-${i}`, cost_usd: 12 - i }) - ), - }); - const first = lines.indexOf(" by prompt of cost"); - - expect(lines.slice(first, first + 12)).toEqual([ - " by prompt of cost", - " p-0 15% $12.00", - " p-1 14% $11.00", - " p-2 13% $10.00", - " p-3 12% $9.00", - " p-4 10% $8.00", - " p-5 9% $7.00", - " p-6 8% $6.00", - " p-7 6% $5.00", - " p-8 5% $4.00", - " p-9 4% $3.00", - " 2 more prompts — see --json for all of them", - ]); - }); - - it("replaces a long period's daily rows with their count, never a partial series", () => { - const lines = threeDay({ - fromDay: "2026-01-01", - toDay: "2026-02-09", - records: [record({ turn_id: "a", cost_usd: 1, event_timestamp: "2026-01-05T00:00:00Z" })], - }); - - expect(lines.slice(-2)).toEqual([ - " by day", - " 40 days in this period — see --json for the daily breakdown", - ]); - }); - - it("prints a session total on its own tool row, never as nothing in this period", () => { - const output = new CapturingOutput(); - printCostReport( - output, - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-17", - declaredTools: [ - { - tool: "copilot", - coverage: "covered", - capability: { - localRead: { - tokenCounters: true, - amount: false, - toolStatedStep: false, - agentName: false, - }, - export: null, - journalAttributable: true, - taskAttributable: false, - }, - }, - ], - records: [ - record({ - tool: "copilot", - kind: "session", - input_tokens: 10, - output_tokens: 42, - cache_creation_tokens: 21070, - }), - ], - journals: [], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }) - ); - - expect(output.lines).toEqual([ - "period 2026-08-17 to 2026-08-17", - "", - " sessions 1", - " requests nothing in this period", - "", - " by tool", - " GitHub Copilot 21,122 tokens (session total, not requests)", - "", - " by day", - " 2026-08-17 nothing in this period", - ]); - }); -}); - -describe("printCostReport — a task the journal declared", () => { - it("says which route named a task row, and gives the rest its own reason", () => { - const lines = threeDay({ - records: [ - record({ turn_id: "a", cost_usd: 2, event_timestamp: "2026-08-17T10:00:00Z" }), - record({ turn_id: "b", cost_usd: 1, event_timestamp: "2026-08-18T10:00:00Z" }), - ], - journals: [ - { - vendorId: "s-1", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [ - { - path: "aidd_docs/tasks/2026_08/2026_08_17_reporting/plan.md", - startMs: Date.parse("2026-08-17T09:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - }, - ], - flowIntervals: [], - }, - ], - }); - const first = lines.indexOf(" by task of cost"); - - expect(lines.slice(first, first + 7)).toEqual([ - " by task of cost", - " 2026_08/2026_08_17_reporting 67% $2.00 declared by the flow", - " the journal falls silent before this record 33% $1.00", - "", - " by backlog item of cost", - " this task declares no backlog item 67% $2.00", - " the journal falls silent before this record 33% $1.00", - ]); - }); -}); - -describe("printCostReport — a selection a filter emptied", () => { - it("says a filter's value was never seen anywhere, and prints no breakdown under it", () => { - expect( - threeDay({ - records: [record({ turn_id: "a", cost_usd: 1 })], - filters: { project: "never-worked-here" }, - }) - ).toEqual([ - "period 2026-08-17 to 2026-08-19 filters: project=never-worked-here", - "", - " project 'never-worked-here' matched nothing — no record has ever named this project", - "", - " sessions nothing in this selection", - " requests nothing in this selection", - ]); - }); - - it("says a known value simply did no work here, rather than never being seen", () => { - expect( - threeDay({ - records: [record({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets" })], - knownValues: { projects: new Set(["gone"]), steps: new Set(), models: new Set() }, - filters: { project: "gone" }, - })[2] - ).toBe(" project 'gone' matched nothing in this selection — known, but no work here"); - }); -}); - -const CODEX_WITH_REASON: CostReportInput["declaredTools"] = [ - { tool: "claude", coverage: "covered", capability: NO_CAPABILITY }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY, reason: "Partial read." }, -]; - -describe("printCostReport — a fact a row carries beside its figure", () => { - it("keeps a covered tool's own reason after the figure it qualifies", () => { - const output = new CapturingOutput(); - printCostReport( - output, - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-17", - declaredTools: CODEX_WITH_REASON, - records: [record({ turn_id: "a", cost_usd: 1, tool: "codex" })], - journals: [], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }) - ); - - expect(output.lines).toContain( - " Codex $1.00 0 tokens — Partial read." - ); - }); - - it("sums all four token counters into the headline, and reports the cache share of them", () => { - const lines = threeDay({ - records: [ - record({ - turn_id: "a", - cost_usd: 4, - input_tokens: 10, - output_tokens: 20, - cache_read_tokens: 30, - cache_creation_tokens: 40, - }), - ], - }); - - expect(lines[4]).toBe(" tokens 100 30% cache"); - }); - - it("names an agent a record's own tool stated, above the main thread's own row", () => { - const lines = threeDay({ - records: [ - record({ turn_id: "a", cost_usd: 4, agent_name: "reviewer" }), - record({ turn_id: "b", cost_usd: 1 }), - ], - }); - const first = lines.indexOf(" by agent of cost"); - - expect(lines.slice(first, first + 3)).toEqual([ - " by agent of cost", - " reviewer 80% $4.00", - " the main thread 20% $1.00", - ]); - }); -}); - -describe("printCostReport — a report narrowed to one task", () => { - it("names the task in the header, and breaks down which route knew it", () => { - const lines = threeDay({ - records: [record({ turn_id: "a", cost_usd: 2, event_timestamp: "2026-08-17T10:00:00Z" })], - journals: [ - { - vendorId: "s-1", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_17_reporting/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ], - task: "2026_08/2026_08_17_reporting", - }); - - expect(lines[0]).toBe("task 2026_08/2026_08_17_reporting 2026-08-17 to 2026-08-19"); - expect(lines.slice(7, 11)).toEqual([ - " ticket known of cost", - " declared by the flow 0%", - " inferred from a written file 100%", - "", - ]); - }); - - it("says a filter emptied a selection only in combination with the rest of it", () => { - const lines = threeDay({ - records: [ - record({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets", model: "opus" }), - record({ turn_id: "b", cost_usd: 1, project_id: "acme/gadgets", model: "haiku" }), - ], - knownValues: { - projects: new Set(["acme/widgets"]), - steps: new Set(), - models: new Set(["haiku"]), - }, - filters: { project: "acme/widgets", model: "haiku" }, - }); - - expect(lines[2]).toBe( - " model 'haiku' matched nothing combined with the rest of this selection" - ); - }); -}); - -describe("printCostReport — the boundary each cap sits on", () => { - it("withholds nothing, and says nothing about withholding, at exactly ten prompts", () => { - const lines = threeDay({ - records: Array.from({ length: 10 }, (_, i) => - record({ turn_id: `t-${i}`, prompt_id: `p-${i}`, cost_usd: 10 - i }) - ), - }); - - expect(lines.filter((line) => line.includes("p-"))).toHaveLength(10); - expect(lines.some((line) => line.includes("more prompts"))).toBe(false); - }); - - it("still prints every daily row at exactly thirty-one days", () => { - const lines = threeDay({ - fromDay: "2026-01-01", - toDay: "2026-01-31", - records: [record({ turn_id: "a", cost_usd: 1, event_timestamp: "2026-01-01T00:00:00Z" })], - }); - - expect(lines.at(-1)).toBe(" 2026-01-31 nothing in this period"); - expect(lines.some((line) => line.includes("days in this period"))).toBe(false); - }); -}); diff --git a/cli/tests/presentation/display/telemetry-check-display.unit.test.ts b/cli/tests/presentation/display/telemetry-check-display.unit.test.ts deleted file mode 100644 index 79cb3af93..000000000 --- a/cli/tests/presentation/display/telemetry-check-display.unit.test.ts +++ /dev/null @@ -1,868 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { TelemetrySetup } from "../../../src/contexts/telemetry/domain/telemetry-setup.js"; -import type { HostRegistrationEntry } from "../../../src/contexts/tools/domain/host-plugin-registration.js"; -import { printTelemetryCheckReport } from "../../../src/presentation/display/telemetry-check-display.js"; -import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; - -/** Everything here is about not sending a person to the wrong place: a gated run must not - * look like a judged one, nor an unreadable thing like an absent one. */ -function reportText(output: CapturingOutput): string { - return output.at("print").join("\n"); -} - -function setup(overrides: Partial = {}): TelemetrySetup { - return { - allowed: { - allowed: true, - readable: true, - location: "/repo/.aidd/config.json", - decidedBy: "project-switch", - }, - identity: { attached: false, path: "/home/.config/aidd/identity.json", readable: true }, - recordsLocation: { path: "/home/.config/aidd/telemetry" }, - hostRegistration: { entries: [] }, - commitTrailer: { - delegate: "executable", - callSite: "present", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - recentlyCarrying: { carrying: 3, examined: 5 }, - }, - recorderDeclaration: { - declared: true, - declaredAt: ["/repo/.aidd/manifest.json"], - locationsChecked: ["/repo/.aidd/manifest.json"], - unreadable: [], - }, - versions: { cli: "5.2.2", plugin: { kind: "recorded", version: "1.0.0" } }, - ...overrides, - }; -} - -describe("the setup a person reads before any claim", () => { - // A gated run judges nothing — but what is in place is exactly what a person switched off - // still needs to see, so the setup is printed on both sides of the gate. - it("prints the setup even when the run was gated before judging anything", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - gate: "measurement is off — nothing to check until it is turned on", - setup: setup(), - leftoverExportConfig: [], - }); - - expect(reportText(output)).toContain("measurement allowed"); - expect(reportText(output)).toContain("records kept at"); - expect(reportText(output)).toContain("measurement is off"); - }); - - // The person's own refusal is a different fact from a project that never turned it on, - // and only one of them is changed by editing the project's file. - it("names a person's own refusal rather than reporting the project as off", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - gate: "measurement is off", - setup: setup({ - allowed: { - allowed: false, - readable: true, - location: "AIDD_TELEMETRY", - decidedBy: "person-refusal", - }, - }), - leftoverExportConfig: [], - }); - - expect(reportText(output)).toContain("this person's own refusal"); - }); - - // Nothing declared is a person's cue to go and declare it somewhere, so the row lists - // every candidate rather than saying only that none matched. - it("lists every location it looked in when nothing declares the recorder", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - setup: setup({ - recorderDeclaration: { - declared: false, - declaredAt: [], - locationsChecked: ["/repo/.aidd/manifest.json", "/repo/.claude/settings.json"], - unreadable: [], - }, - }), - claims: [], - uncovered: [], - leftoverExportConfig: [], - }); - - expect(reportText(output)).toContain("nowhere this build checks"); - expect(reportText(output)).toContain("/repo/.claude/settings.json"); - }); - - // A damaged file is something to fix; an absent declaration is an ordinary state. The row - // must not print the first as the second. - it("reads a damaged declaration location as unreadable, not as undeclared", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - setup: setup({ - recorderDeclaration: { - declared: false, - declaredAt: [], - locationsChecked: [], - unreadable: ["/repo/.aidd/manifest.json"], - }, - }), - claims: [], - uncovered: [], - leftoverExportConfig: [], - }); - - expect(reportText(output)).toContain("could not be read"); - expect(reportText(output)).not.toContain("nowhere this build checks"); - }); - - it("names a plugin version nothing journalled apart from one that was never stamped", () => { - const nothing = new CapturingOutput(); - printTelemetryCheckReport(nothing, { - setup: setup({ versions: { cli: "5.2.2", plugin: { kind: "nothing-journalled" } } }), - claims: [], - uncovered: [], - leftoverExportConfig: [], - }); - const unstamped = new CapturingOutput(); - printTelemetryCheckReport(unstamped, { - setup: setup({ versions: { cli: "5.2.2", plugin: { kind: "unrecorded" } } }), - claims: [], - uncovered: [], - leftoverExportConfig: [], - }); - - const line = (o: CapturingOutput) => - o.at("print").find((l) => l.includes("plugin version")) ?? ""; - expect(line(nothing)).not.toBe(line(unstamped)); - }); -}); - -describe("the claims, and what is deliberately not one", () => { - it("prints a verdict and its detail for every claim judged", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - setup: setup(), - claims: [ - { - claim: "hook-fired", - verdict: "ok", - reason: "session-anchored", - detail: "2 run file(s)", - }, - { - claim: "records-join", - verdict: "fail", - reason: "all-unattributed", - detail: "nothing joined", - }, - ], - uncovered: [], - leftoverExportConfig: [], - }); - - expect(reportText(output)).toContain("2 run file(s)"); - expect(reportText(output)).toContain("nothing joined"); - }); - - it("names a tool nothing can read with its own reason, never as a failing claim", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - setup: setup(), - claims: [], - uncovered: [{ tool: "cursor", reason: "It writes no token count in any file it produces." }], - leftoverExportConfig: [], - }); - - expect(reportText(output)).toContain("not covered: cursor"); - expect(reportText(output)).toContain("no token count"); - }); - - // A stale export lives in a tool's own settings file, which no claim here can see — so it - // is a warning on stderr, never one of the judged four. - it("warns about a leftover export on stderr, on both sides of the gate", () => { - const leftoverExportConfig = [ - { path: "/repo/.claude/settings.json", keys: ["OTEL_EXPORTER_OTLP_ENDPOINT"] }, - ]; - - const judged = new CapturingOutput(); - printTelemetryCheckReport(judged, { - setup: setup(), - claims: [], - uncovered: [], - leftoverExportConfig, - }); - const gated = new CapturingOutput(); - printTelemetryCheckReport(gated, { - gate: "measurement is off", - setup: setup(), - leftoverExportConfig, - }); - - for (const output of [judged, gated]) { - expect(output.at("warn").join("\n")).toContain("OTEL_EXPORTER_OTLP_ENDPOINT"); - expect(reportText(output)).not.toContain("OTEL_EXPORTER_OTLP_ENDPOINT"); - } - }); -}); - -describe("the row saying whether the host will load what aidd installed", () => { - function report(hostRegistration: TelemetrySetup["hostRegistration"]): string { - const output = new CapturingOutput(); - printTelemetryCheckReport(output, { - gate: "measurement is off", - setup: setup({ hostRegistration }), - leftoverExportConfig: [], - }); - return reportText(output); - } - - const REGISTRY = "/home/dev/.claude/plugins/installed_plugins.json"; - - function entry(answer: HostRegistrationEntry["answer"], plugin: string): HostRegistrationEntry { - return { tool: "claude", plugin, answer, detail: REGISTRY }; - } - - // The one thing a person must not miss is what will not load. A reader who stops after the - // first line has still read the problem. - it("puts what will not load above what is fine", () => { - const text = report({ - entries: [entry("registered", "fine"), entry("not-registered", "broken")], - }); - - expect(text.indexOf("broken")).toBeLessThan(text.indexOf("fine")); - }); - - it("orders a disabled registration and an unanswerable one between the two", () => { - const text = report({ - entries: [ - entry("registered", "fine"), - entry("unanswerable", "unknown"), - entry("registered-disabled", "off"), - entry("not-registered", "broken"), - ], - }); - const at = (plugin: string) => text.indexOf(plugin); - - expect(at("broken")).toBeLessThan(at("off")); - expect(at("off")).toBeLessThan(at("unknown")); - expect(at("unknown")).toBeLessThan(at("fine")); - }); - - it("names the answer and the detail on each line, never a bare pass", () => { - const text = report({ - entries: [{ ...entry("not-registered", "aidd-telemetry"), detail: "does not carry it" }], - }); - - expect(text).toContain("claude/aidd-telemetry: not-registered — does not carry it"); - }); - - // A project with nothing installed is healthy, and an empty block would read as a failure - // to look rather than as an answer. - it("says a project has no plugin recorded rather than printing nothing", () => { - expect(report({ entries: [] })).toContain("no plugin recorded for any tool"); - }); - - // The crash guard, made visible: a manifest that cannot be parsed is its own sentence, and - // must never print as the empty case above — one is damage, the other is a normal state. - it("says the manifest could not be read, distinctly from having nothing installed", () => { - const text = report({ - entries: [], - manifestUnreadable: "Cannot read properties of undefined (reading 'map')", - }); - - expect(text).toContain("AIDD's own manifest could not be read"); - expect(text).not.toContain("no plugin recorded"); - }); -}); - -describe("the row saying whether commits carry their session", () => { - function report(commitTrailer: TelemetrySetup["commitTrailer"]): string { - const output = new CapturingOutput(); - printTelemetryCheckReport(output, { - gate: "measurement is off", - setup: setup({ commitTrailer }), - leftoverExportConfig: [], - }); - return reportText(output); - } - - const HEALTHY = { - delegate: "executable", - callSite: "present", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - } as const; - - // The count leads, because it is the only fact here about the chain rather than its parts. - // A person who reads one line has read whether it is working. - it("leads with how many recent commits carry it", () => { - const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 4, examined: 20 } }); - - expect(text).toContain("4 of the last 20 commits carry it"); - }); - - it("says nothing about pieces when every piece is in place", () => { - const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 20, examined: 20 } }); - // Scoped to this row: every other setup row uses a dash of its own, so asserting over - // the whole report would only prove the report has dashes in it. - const row = text.split("\n").find((line) => line.includes("commit trailer")) ?? ""; - - expect(row).not.toContain("—"); - expect(text).toContain("hooks run from /repo/.git/hooks"); - }); - - it("names each missing piece after the count", () => { - const text = report({ - ...HEALTHY, - delegate: "absent", - callSite: "missing", - recentlyCarrying: { carrying: 0, examined: 20 }, - }); - - expect(text).toContain("0 of the last 20 commits carry it"); - expect(text).toContain("nothing installed to write it"); - expect(text).toContain("prepare-commit-msg does not call it"); - }); - - // Zero with every part in place is the finding this row exists to surface, so excusing it - // as by-design is the one outcome that must be impossible. - it("never excuses zero, whatever else is in place", () => { - const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 0, examined: 20 } }); - - expect(text).toContain("0 of the last 20 commits carry it"); - expect(text).not.toContain("by design"); - }); - - it("says a hook git will not run is not executable", () => { - const text = report({ ...HEALTHY, hookExecutable: false }); - - expect(text).toContain("prepare-commit-msg is not executable"); - }); - - it("says a delegate that is not executable will not be run", () => { - expect(report({ ...HEALTHY, delegate: "not-executable" })).toContain("not executable"); - }); - - // Said, never named. Which tool owns the file changes nothing a person does about it. - it("says the hook is somebody else's without naming a tool", () => { - const text = report({ ...HEALTHY, hookHasOtherContent: true }); - - expect(text).toContain("somebody else's"); - expect(text).not.toMatch(/lefthook|husky/iu); - }); - - /** `hookManager` is read from a root marker file, never from the hook's own contents, which - * is what makes naming a tool honest: a hand-written hook stays unnamed. */ - describe("where lefthook or husky owns prepare-commit-msg", () => { - it("names lefthook and prints the job to add, when its config does not call the delegate", () => { - const text = report({ - delegate: "absent", - callSite: "missing", - hookHasOtherContent: true, - hooksDir: "/repo/.git/hooks", - hookManager: "lefthook", - managerCallsDelegate: false, - }); - - expect(text).toContain("lefthook"); - expect(text).toContain("prepare-commit-msg:"); - expect(text).toContain("add this command under `prepare-commit-msg:`"); - expect(text).not.toContain("does not call it"); - }); - - it("names husky and prints the line to add, when its config does not call the delegate", () => { - const text = report({ - delegate: "absent", - callSite: "missing", - hookHasOtherContent: true, - hooksDir: "/repo/.git/hooks", - hookManager: "husky", - managerCallsDelegate: false, - }); - - expect(text).toContain("husky"); - expect(text).toContain(".husky/prepare-commit-msg"); - expect(text).toContain("add this line to .husky/prepare-commit-msg"); - expect(text).not.toContain("does not call it"); - }); - - // `callSite: "missing"` describes the absolute-path line this CLI looks for, which a - // manager never calls the delegate through — not a fault once its own config does. - it("reports the chain wired through the manager and prints no job, once its config already calls the delegate", () => { - const text = report({ - delegate: "executable", - callSite: "missing", - hookHasOtherContent: true, - hooksDir: "/repo/.git/hooks", - hookManager: "lefthook", - managerCallsDelegate: true, - }); - - expect(text).toContain("wired through lefthook"); - expect(text).not.toContain("does not call it"); - expect(text).not.toContain("add this command"); - expect(text).not.toContain("prepare-commit-msg:"); - }); - - // A config naming the job is not the delegate being there to answer it: a checkout where - // `telemetry on` was never run must not read as wired. - it("does not report wired when nothing was ever installed to answer the call", () => { - const text = report({ - delegate: "absent", - callSite: "missing", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - hookManager: "lefthook", - managerCallsDelegate: true, - }); - - expect(text).not.toContain("wired through lefthook's own prepare-commit-msg"); - expect(text).toContain("nothing installed to write it"); - expect(text).toContain("aidd telemetry on"); - }); - - it("says the script is not executable rather than wired, when it cannot run", () => { - const text = report({ - delegate: "not-executable", - callSite: "missing", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - hookManager: "husky", - managerCallsDelegate: true, - }); - - expect(text).not.toContain("wired through husky's own prepare-commit-msg"); - expect(text).toContain("not executable"); - }); - }); - - // A commit no session made carries no trailer by design and merges are skipped, so a bare - // "4 of 20" would read as a fault when it is not one. - it("says a shortfall is expected when every part is in place", () => { - const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 4, examined: 20 } }); - - expect(text).toContain("a commit no session made carries none, by design"); - }); - - it("does not excuse a shortfall when a part is broken", () => { - const text = report({ - ...HEALTHY, - callSite: "missing", - recentlyCarrying: { carrying: 4, examined: 20 }, - }); - - expect(text).not.toContain("by design"); - expect(text).toContain("prepare-commit-msg does not call it"); - }); - - // Outside a repository there is no hook to carry anything, which the claims below already - // refuse to read as a failure. "nothing installed" would describe a repository we are not in. - it("says there is no repository rather than listing missing pieces", () => { - const text = report({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: "no-repository", - }); - - expect(text).toContain("no repository here"); - expect(text).not.toContain("nothing installed"); - }); - - // A git that rejects `--git-path` still sits inside a repository with a history, so the - // count stays the fact that matters. - it("keeps the count when git could not name the hooks directory", () => { - const text = report({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: "unresolved", - recentlyCarrying: { carrying: 1, examined: 4 }, - }); - - expect(text).toContain("1 of the last 4 commits carry it"); - expect(text).not.toContain("no repository here"); - }); - - // No commits and no commits carrying it are different facts, and only the second is - // something to act on. - it("says there is no history to read rather than reporting zero", () => { - const text = report(HEALTHY); - - expect(text).toContain("no commit history to read"); - expect(text).not.toContain("0 of the last"); - }); -}); - -const RECORDS_ROW = - " records kept at /home/.config/aidd/telemetry (override with AIDD_TELEMETRY_DIR)"; -const BY_DESIGN_TRAILER_ROW = - " commit trailer 3 of the last 5 commits carry it — a commit no session made " + - "carries none, by design\n hooks run from /repo/.git/hooks"; - -function reportLines(result: Parameters[1]): string[] { - const output = new CapturingOutput(); - printTelemetryCheckReport(output, result); - return output.at("print"); -} - -describe("printTelemetryCheckReport — the setup block, whole", () => { - it("prints eight labelled rows, a blank line, then the gate that stopped the run", () => { - expect( - reportLines({ - gate: "measurement is off — nothing to check until it is turned on", - setup: setup(), - leftoverExportConfig: [], - }) - ).toEqual([ - " measurement allowed yes — /repo/.aidd/config.json", - " identity attached no — /home/.config/aidd/identity.json", - RECORDS_ROW, - " recorder declared yes — /repo/.aidd/manifest.json", - " plugins registered no plugin recorded for any tool", - BY_DESIGN_TRAILER_ROW, - " cli version 5.2.2", - " plugin version 1.0.0 (as the hook recorded it)", - "", - " measurement is off — nothing to check until it is turned on", - ]); - }); - - it("reads every unreadable location as unreadable, never as an absent one", () => { - expect( - reportLines({ - gate: "off", - setup: setup({ - allowed: { - allowed: false, - readable: false, - location: "/repo/.aidd/config.json", - decidedBy: "project-switch", - }, - identity: { attached: false, path: "/h/identity.json", readable: false }, - recorderDeclaration: { - declared: false, - declaredAt: [], - locationsChecked: [], - unreadable: ["/repo/.aidd/manifest.json"], - }, - hostRegistration: { entries: [], manifestUnreadable: "boom" }, - versions: { cli: "5.2.2", plugin: { kind: "nothing-journalled" } }, - }), - leftoverExportConfig: [], - }).slice(0, 8) - ).toEqual([ - " measurement allowed could not be read — /repo/.aidd/config.json", - " identity attached could not be read — /h/identity.json", - RECORDS_ROW, - " recorder declared could not be read — /repo/.aidd/manifest.json", - " plugins registered AIDD's own manifest could not be read — boom", - BY_DESIGN_TRAILER_ROW, - " cli version 5.2.2", - " plugin version no session journalled yet", - ]); - }); - - it("names a person's own refusal, an attached identity and the locations it looked in", () => { - expect( - reportLines({ - gate: "off", - setup: setup({ - allowed: { - allowed: false, - readable: true, - location: "AIDD_TELEMETRY", - decidedBy: "person-refusal", - }, - identity: { attached: true, path: "/h/identity.json", readable: true }, - recorderDeclaration: { - declared: false, - declaredAt: [], - locationsChecked: ["/repo/.aidd/manifest.json", "/repo/.claude/settings.json"], - unreadable: [], - }, - versions: { cli: "5.2.2", plugin: { kind: "unrecorded" } }, - }), - leftoverExportConfig: [], - }).slice(0, 8) - ).toEqual([ - " measurement allowed no — this person's own refusal (AIDD_TELEMETRY)", - " identity attached yes — /h/identity.json", - RECORDS_ROW, - " recorder declared nowhere this build checks — looked in:\n" + - " /repo/.aidd/manifest.json\n /repo/.claude/settings.json", - " plugins registered no plugin recorded for any tool", - BY_DESIGN_TRAILER_ROW, - " cli version 5.2.2", - " plugin version unknown — no journalled session names one. The plugin's own " + - "manifest was not beside its hooks and no `aidd` install recorded it; `aidd plugin " + - "install aidd-telemetry` would make it known.", - ]); - }); -}); - -describe("printTelemetryCheckReport — every claim, in its own column", () => { - it("prints each claim's own name and verdict token, then its detail", () => { - expect( - reportLines({ - setup: setup(), - claims: [ - { - claim: "hook-fired", - verdict: "ok", - reason: "session-anchored", - detail: "2 run file(s)", - }, - { - claim: "session-journalled", - verdict: "unknown", - reason: "no-session-named", - detail: "none yet", - }, - { - claim: "tool-files-readable", - verdict: "fail", - reason: "no-run-file-to-read", - detail: "EACCES", - }, - { claim: "records-join", verdict: "fail", reason: "all-unattributed", detail: "none" }, - ], - uncovered: [{ tool: "cursor", reason: "It writes no token count." }], - leftoverExportConfig: [], - }).slice(9) - ).toEqual([ - " hook fired ok 2 run file(s)", - " session journalled -- none yet", - " tool files readable FAIL EACCES", - " records join FAIL none", - " not covered: cursor -- It writes no token count.", - ]); - }); - - it("names every key of a leftover export, and what to do about it, on stderr", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - gate: "off", - setup: setup(), - leftoverExportConfig: [ - { path: "/repo/.claude/settings.json", keys: ["OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_A"] }, - ], - }); - - expect(output.at("warn")).toEqual([ - "/repo/.claude/settings.json still sets OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_A — delete " + - "these keys from its `env` block by hand to stop that export; nothing here can do it " + - "for you.", - ]); - }); -}); - -describe("printTelemetryCheckReport — the plugins-registered headline", () => { - function pluginsRow(hostRegistration: TelemetrySetup["hostRegistration"]): string { - return ( - reportLines({ - gate: "off", - setup: setup({ hostRegistration }), - leftoverExportConfig: [], - })[4] ?? "" - ); - } - - it("counts what will not load against the total, worst first", () => { - expect( - pluginsRow({ - entries: [ - { tool: "claude", plugin: "fine", answer: "registered", detail: "R" }, - { tool: "claude", plugin: "broken", answer: "not-registered", detail: "R" }, - ], - }) - ).toBe( - " plugins registered 1 of 2 will not load, or could not be answered\n" + - " claude/broken: not-registered — R\n claude/fine: registered — R" - ); - }); - - it("says all of them will load when none is in trouble", () => { - expect( - pluginsRow({ - entries: [ - { tool: "claude", plugin: "a", answer: "registered", detail: "R" }, - { tool: "codex", plugin: "b", answer: "registered", detail: "R" }, - ], - }) - ).toBe( - " plugins registered all 2 will load\n claude/a: registered — R\n" + - " codex/b: registered — R" - ); - }); -}); - -describe("printTelemetryCheckReport — the commit-trailer row, word for word", () => { - const HEALTHY_ROW = { - delegate: "executable", - callSite: "present", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - } as const; - const HOOKS_FROM = "\n hooks run from /repo/.git/hooks"; - - function trailerRow(commitTrailer: TelemetrySetup["commitTrailer"]): string { - return ( - reportLines({ gate: "off", setup: setup({ commitTrailer }), leftoverExportConfig: [] })[5] ?? - "" - ); - } - - it("says only the count when every commit carries it", () => { - expect(trailerRow({ ...HEALTHY_ROW, recentlyCarrying: { carrying: 20, examined: 20 } })).toBe( - ` commit trailer 20 of the last 20 commits carry it${HOOKS_FROM}` - ); - }); - - it("excuses a shortfall only where some commits carry it and every part is in place", () => { - expect(trailerRow({ ...HEALTHY_ROW, recentlyCarrying: { carrying: 4, examined: 20 } })).toBe( - " commit trailer 4 of the last 20 commits carry it — a commit no session made " + - `carries none, by design${HOOKS_FROM}` - ); - }); - - it("never excuses zero, however healthy every part is", () => { - expect(trailerRow({ ...HEALTHY_ROW, recentlyCarrying: { carrying: 0, examined: 20 } })).toBe( - ` commit trailer 0 of the last 20 commits carry it${HOOKS_FROM}` - ); - }); - - it("says there is no history to read rather than reporting a zero", () => { - expect(trailerRow(HEALTHY_ROW)).toBe( - ` commit trailer no commit history to read${HOOKS_FROM}` - ); - }); - - it("names every broken piece after the count, in one sentence", () => { - expect( - trailerRow({ - ...HEALTHY_ROW, - delegate: "absent", - callSite: "missing", - hookExecutable: false, - hookHasOtherContent: true, - recentlyCarrying: { carrying: 0, examined: 20 }, - }) - ).toBe( - " commit trailer 0 of the last 20 commits carry it — nothing installed to write " + - "it; prepare-commit-msg does not call it; prepare-commit-msg is not executable, so git " + - `ignores it; that hook is somebody else's too${HOOKS_FROM}` - ); - }); - - it("says a delegate git cannot run is not executable", () => { - expect(trailerRow({ ...HEALTHY_ROW, delegate: "not-executable" })).toBe( - " commit trailer no commit history to read — its script is not executable, so " + - `git will not run it${HOOKS_FROM}` - ); - }); - - it("says there is no hook file at all, rather than that one does not call the delegate", () => { - expect(trailerRow({ ...HEALTHY_ROW, callSite: "no-hook-file" })).toBe( - ` commit trailer no commit history to read — there is no prepare-commit-msg${HOOKS_FROM}` - ); - }); - - it("says there is no repository, and names no hooks directory it does not have", () => { - expect( - trailerRow({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: "no-repository", - }) - ).toBe(" commit trailer no repository here, so no hook to carry it"); - }); - - it("keeps the count when git would not say where it runs hooks from", () => { - expect( - trailerRow({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: "unresolved", - recentlyCarrying: { carrying: 1, examined: 4 }, - }) - ).toBe( - " commit trailer 1 of the last 4 commits carry it — git could not say where it " + - "runs hooks from" - ); - }); -}); - -describe("printTelemetryCheckReport — where lefthook or husky owns the hook", () => { - const HOOKS_FROM = "\n hooks run from /repo/.git/hooks"; - - function trailerRow(commitTrailer: TelemetrySetup["commitTrailer"]): string { - return ( - reportLines({ gate: "off", setup: setup({ commitTrailer }), leftoverExportConfig: [] })[5] ?? - "" - ); - } - - it("reports the chain wired through the manager once its config calls the delegate", () => { - expect( - trailerRow({ - delegate: "executable", - callSite: "missing", - hookHasOtherContent: true, - hooksDir: "/repo/.git/hooks", - hookManager: "lefthook", - managerCallsDelegate: true, - recentlyCarrying: { carrying: 2, examined: 4 }, - }) - ).toBe( - " commit trailer 2 of the last 4 commits carry it — a commit no session made " + - "carries none, by design — wired through lefthook's own prepare-commit-msg" + - HOOKS_FROM - ); - }); - - it("refuses to call a checkout wired when nothing was installed to answer the call", () => { - expect( - trailerRow({ - delegate: "absent", - callSite: "missing", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - hookManager: "lefthook", - managerCallsDelegate: true, - }) - ).toBe( - " commit trailer no commit history to read — wired through lefthook, but nothing " + - `installed to write it; run \`aidd telemetry on\`${HOOKS_FROM}` - ); - }); - - it("names an unrunnable script rather than reporting the manager as wired", () => { - expect( - trailerRow({ - delegate: "not-executable", - callSite: "missing", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - hookManager: "husky", - managerCallsDelegate: true, - }) - ).toBe( - " commit trailer no commit history to read — wired through husky, but its script " + - `is not executable, so git will not run it; run \`aidd telemetry on\`${HOOKS_FROM}` - ); - }); -}); diff --git a/cli/tests/presentation/display/telemetry-display.unit.test.ts b/cli/tests/presentation/display/telemetry-display.unit.test.ts deleted file mode 100644 index f9868f2eb..000000000 --- a/cli/tests/presentation/display/telemetry-display.unit.test.ts +++ /dev/null @@ -1,817 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; -import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; -import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import type { - LocalCostToolReport, - LocalCostToolStatus, - ReadLocalCostResult, -} from "../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; -import type { TelemetrySink } from "../../../src/contexts/telemetry/domain/ports/telemetry-sink.js"; -import { - printLocalCostReadReport, - printPersonIdentityLink, - printPersonIdentityOff, - printPersonIdentityStatus, - printPersonIdentityUnlink, - printPersonIdentityUse, - printTelemetryOffReport, - printTelemetryOnReport, - warnIfFiguresMoveTheTokenToo, -} from "../../../src/presentation/display/telemetry-display.js"; -import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; -import { InMemoryTelemetrySink } from "../../helpers/ports/in-memory-telemetry-sink.js"; - -function textOf(output: CapturingOutput): string { - return output.lines.join("\n"); -} - -function toolReport(overrides: Partial = {}): LocalCostToolReport { - return { - tool: "claude", - status: "found", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - ...overrides, - }; -} - -function readResult(overrides: Partial = {}): ReadLocalCostResult { - return { sessions: [], toolReports: [], ...overrides }; -} - -describe("what `telemetry read` says about each tool", () => { - /** Five of the six statuses mean something other than "this tool billed nothing", so the - * labels are held apart rather than matched against a wording that may change. */ - it("gives every status a label of its own, so no two can be read as the same fact", () => { - const statuses: readonly LocalCostToolStatus[] = [ - "found", - "empty", - "not-found", - "unreadable", - "not-covered", - "not-asked", - ]; - - const labels = statuses.map((status) => { - const output = new CapturingOutput(); - printLocalCostReadReport( - output, - readResult({ - sessions: [{ sessionId: "s-1", toolReports: [] }], - toolReports: [toolReport({ status })], - }) - ); - return output.lines[output.lines.length - 1]; - }); - - expect(new Set(labels).size).toBe(statuses.length); - }); - - it("never says a tool found nothing when nothing was ever asked of it", () => { - const output = new CapturingOutput(); - - printLocalCostReadReport( - output, - readResult({ - sessions: [{ sessionId: "s-1", toolReports: [] }], - toolReports: [toolReport({ status: "not-asked" })], - }) - ); - - expect(textOf(output)).not.toMatch(/nothing found/u); - }); - - // A sweep that read nineteen sessions and failed the twentieth still reports the figures - // as read; the failure has to survive beside the status, never inside it. - it("names a session that could not be read beside a tool that otherwise read fine", () => { - const output = new CapturingOutput(); - - printLocalCostReadReport( - output, - readResult({ - sessions: [{ sessionId: "s-1", toolReports: [] }], - toolReports: [ - toolReport({ - status: "found", - recordsFound: 3, - recordsStored: 3, - sessionsFailed: 1, - failureReason: "EACCES", - }), - ], - }) - ); - - expect(textOf(output)).toContain("1 session could not be read"); - expect(textOf(output)).toContain("EACCES"); - }); - - // A refusal read nothing and stored nothing. "No session journalled yet" is a fact about - // the journal; conflating the two sends a person to look at the wrong thing. - it("tells a refusal apart from an empty journal", () => { - const refused = new CapturingOutput(); - printLocalCostReadReport(refused, readResult({ refusedReason: "measurement is off" })); - - const empty = new CapturingOutput(); - printLocalCostReadReport(empty, readResult()); - - expect(textOf(refused)).toContain("measurement is off"); - expect(textOf(empty)).toContain("No session journalled yet"); - expect(textOf(refused)).not.toContain("No session journalled yet"); - }); - - it("leads with how many sessions it covered, not one line per tool per session", () => { - const output = new CapturingOutput(); - - printLocalCostReadReport( - output, - readResult({ - sessions: [ - { sessionId: "s-1", toolReports: [] }, - { sessionId: "s-2", toolReports: [] }, - ], - toolReports: [toolReport({ status: "found", recordsFound: 2, recordsStored: 2 })], - }) - ); - - expect(output.lines[0]).toContain("2 sessions read"); - }); -}); - -describe("what the switch says when it is flipped", () => { - it("says the file is tracked, because turning it on decides for everyone who clones", () => { - const output = new CapturingOutput(); - - printTelemetryOnReport(output, { switchPath: "/repo/.aidd/config.json", switchChanged: true }); - - expect(textOf(output)).toContain("git-tracked"); - }); - - it("tells an already-on project from one it just turned on", () => { - const changed = new CapturingOutput(); - printTelemetryOnReport(changed, { switchPath: "/p/.aidd/config.json", switchChanged: true }); - const unchanged = new CapturingOutput(); - printTelemetryOnReport(unchanged, { switchPath: "/p/.aidd/config.json", switchChanged: false }); - - expect(textOf(changed)).not.toContain("already on"); - expect(textOf(unchanged)).toContain("already on"); - }); - - // Turning recording off is not erasing what was recorded, and a person who wanted the - // second has to be told which command does it. - it("says off stops new recording only, and names what removes the rest", () => { - const output = new CapturingOutput(); - - printTelemetryOffReport(output, { switchPath: "/p/.aidd/config.json", switchChanged: true }); - - expect(textOf(output)).toContain("stops new recording only"); - expect(textOf(output)).toContain("aidd telemetry forget"); - }); -}); - -describe("what the identity commands say", () => { - it("says records carry no person when nobody has chosen", () => { - const output = new CapturingOutput(); - - printPersonIdentityStatus(output, { filePath: "/h/identity.json", identity: null }); - - expect(textOf(output)).toContain("records carry no person"); - }); - - it("tells an identifier minted here from one taken from another machine", () => { - const minted = new CapturingOutput(); - printPersonIdentityStatus(minted, { - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "minted", alsoMe: [] }, - }); - const adopted = new CapturingOutput(); - printPersonIdentityStatus(adopted, { - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "adopted", alsoMe: [] }, - }); - - expect(textOf(minted)).toContain("minted on this machine"); - expect(textOf(adopted)).toContain("taken from another machine"); - }); - - // Taking a different identifier does not rewrite what is already stored, and a person - // has to be told which identifier those records keep. - it("names the identifier that was replaced, when one was", () => { - const output = new CapturingOutput(); - - printPersonIdentityUse(output, { - filePath: "/h/identity.json", - identity: { personId: "p-2", origin: "adopted", alsoMe: [] }, - outcome: "adopted", - replacedPersonId: "p-1", - }); - - expect(textOf(output)).toContain("p-1"); - }); - - it("says withdrawing takes the added identifiers with it", () => { - const output = new CapturingOutput(); - - printPersonIdentityOff(output, { - filePath: "/h/identity.json", - removed: true, - discardedDamaged: false, - addedIdentifiersRemoved: 2, - }); - - expect(textOf(output)).toMatch(/2/u); - }); -}); - -describe("the warning about where the figures land", () => { - /** The real in-memory sink with only the field this printer reads — never an object literal - * widened into the port, which would stop failing the day the port grows a member. */ - function sink(locatedBy: TelemetrySink["locatedBy"]): TelemetrySink { - const built = new InMemoryTelemetrySink(); - built.locatedBy = locatedBy; - return built; - } - - // `AIDD_USER_CONFIG_DIR` names the directory that also holds `auth.json`. A person who - // pointed it somewhere shared moved their GitHub token there too, and nothing else says so. - it("warns when the figures were placed by the variable that also moves the token", () => { - const output = new CapturingOutput(); - - warnIfFiguresMoveTheTokenToo(output, sink("user-config-dir")); - - expect(textOf(output)).toContain("auth.json"); - }); - - it("says nothing when the directory was named outright, or defaulted", () => { - for (const locatedBy of ["telemetry-dir", "default"] as const) { - const output = new CapturingOutput(); - warnIfFiguresMoveTheTokenToo(output, sink(locatedBy)); - expect(output.lines).toEqual([]); - } - }); -}); - -describe("linking an identifier this person could not simply take as their own", () => { - // `link` is a claim the tool cannot verify — it never checks who is running it — so every - // path that writes one has to say so. - it("says a fresh link is a declaration nothing here can check", () => { - const output = new CapturingOutput(); - - printPersonIdentityLink(output, { - filePath: "/h/identity.json", - personId: "p-1", - identity: "machine-2", - alreadyListed: false, - }); - - expect(textOf(output)).toContain("linked 'machine-2'"); - expect(textOf(output)).toContain("cannot check"); - }); - - // Already listed is a no-op, not a second write, and a caller that links before reporting - // has to be able to tell the two apart. - it("reports one already listed as already listed, never as a fresh write", () => { - const output = new CapturingOutput(); - - printPersonIdentityLink(output, { - filePath: "/h/identity.json", - personId: "p-1", - identity: "machine-2", - alreadyListed: true, - }); - - expect(textOf(output)).toContain("already listed"); - expect(textOf(output)).not.toContain("linked 'machine-2'"); - }); - - it("reports unlinking one nobody listed as nothing to remove, never a failure", () => { - const output = new CapturingOutput(); - - printPersonIdentityUnlink(output, { - filePath: "/h/identity.json", - identity: "machine-9", - removed: false, - }); - - expect(textOf(output)).toContain("nothing to remove"); - }); - - it("names the identifier it withdrew", () => { - const output = new CapturingOutput(); - - printPersonIdentityUnlink(output, { - filePath: "/h/identity.json", - identity: "machine-2", - removed: true, - }); - - expect(textOf(output)).toContain("unlinked 'machine-2'"); - }); -}); - -describe("what minting says it does, and does not do", () => { - // The consent a person gives is to this sentence, so both halves of it are asserted. - it("names what the identifier attaches to, and what it never attaches to", () => { - const output = new CapturingOutput(); - - printPersonIdentityUse(output, { - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "minted", alsoMe: [] }, - outcome: "minted", - }); - - expect(textOf(output)).toContain("records this machine reads locally"); - expect(textOf(output)).toContain("Never attaches to"); - }); - - // "Already in effect" is true of the identifier and false of the file once a name came - // with the call: something was written, and the first line must not say otherwise. - it("does not claim nothing changed when a display name was set alongside", () => { - const output = new CapturingOutput(); - - printPersonIdentityUse(output, { - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "minted", alsoMe: [], displayName: "Ada" }, - outcome: "unchanged", - displayNameSet: "Ada", - }); - - expect(textOf(output)).toContain("display name set"); - expect(textOf(output)).toContain("Ada"); - }); - - it("says withdrawing never gives the same identifier back", () => { - const output = new CapturingOutput(); - - printPersonIdentityOff(output, { - filePath: "/h/identity.json", - removed: true, - discardedDamaged: false, - addedIdentifiersRemoved: 0, - }); - - expect(textOf(output)).toContain("mints a fresh identifier, never this one back"); - }); - - it("reports nothing to withdraw when nobody had chosen", () => { - const output = new CapturingOutput(); - - printPersonIdentityOff(output, { - filePath: "/h/identity.json", - removed: false, - discardedDamaged: false, - addedIdentifiersRemoved: 0, - }); - - expect(textOf(output)).toContain("already off"); - }); - - // Withdrawing has to work exactly when the file is too damaged to read, and say that it - // discarded rather than read it. - it("says a damaged file was discarded rather than left behind", () => { - const output = new CapturingOutput(); - - printPersonIdentityOff(output, { - filePath: "/h/identity.json", - removed: true, - discardedDamaged: true, - addedIdentifiersRemoved: 0, - }); - - expect(textOf(output)).toContain("discarded rather than left behind"); - }); -}); - -const DISCLAIMER = - " This is a declaration the tool cannot check - it never verifies who is running it."; -const IDENTITY_FILE = "/h/identity.json"; - -function printed(print: (output: CapturingOutput) => void): string[] { - const output = new CapturingOutput(); - print(output); - return output.lines; -} - -describe("printTelemetryOnReport", () => { - it("names the switch file it wrote, then that everyone who clones gets it", () => { - expect( - printed((output) => - printTelemetryOnReport(output, { - switchPath: "/repo/.aidd/config.json", - switchChanged: true, - }) - ) - ).toEqual([ - "AIDD telemetry: on (/repo/.aidd/config.json)", - "/repo/.aidd/config.json is git-tracked — this applies to everyone who clones.", - ]); - }); - - it("says already on where nothing was written", () => { - expect( - printed((output) => - printTelemetryOnReport(output, { - switchPath: "/repo/.aidd/config.json", - switchChanged: false, - }) - )[0] - ).toBe("AIDD telemetry: already on (/repo/.aidd/config.json)"); - }); -}); - -describe("printTelemetryOffReport", () => { - it("names what stops and what stays, and the command that removes the rest", () => { - expect( - printed((output) => - printTelemetryOffReport(output, { - switchPath: "/p/.aidd/config.json", - switchChanged: true, - }) - ) - ).toEqual([ - "AIDD telemetry: off (/p/.aidd/config.json)", - "This stops new recording only — sessions already journalled stay in aidd_docs/runs/ " + - "and whatever `aidd telemetry read` already stored, and `aidd telemetry report` still " + - "reports them. Run `aidd telemetry forget` to remove what was already measured.", - ]); - }); - - it("says already off where nothing was written", () => { - expect( - printed((output) => - printTelemetryOffReport(output, { - switchPath: "/p/.aidd/config.json", - switchChanged: false, - }) - )[0] - ).toBe("AIDD telemetry: already off (/p/.aidd/config.json)"); - }); -}); - -describe("printLocalCostReadReport — the line each tool gets", () => { - function toolLine(overrides: Partial): string { - return printed((output) => - printLocalCostReadReport( - output, - readResult({ - sessions: [{ sessionId: "s-1", toolReports: [] }], - toolReports: [toolReport(overrides)], - }) - ) - )[1] as string; - } - - it("counts what was stored against what was found, on a tool that read something", () => { - expect(toolLine({ status: "found", recordsFound: 5, recordsStored: 3 })).toBe( - " Claude Code: read (3 new of 5)" - ); - }); - - it("counts nothing on a status other than found, whose counts would mean nothing", () => { - expect(toolLine({ status: "empty", recordsFound: 5, recordsStored: 3 })).toBe( - " Claude Code: read, nothing found" - ); - }); - - it("gives each status its own words, none of them readable as another", () => { - expect([ - toolLine({ status: "not-found" }), - toolLine({ status: "unreadable" }), - toolLine({ status: "not-covered" }), - toolLine({ status: "not-asked" }), - ]).toEqual([ - " Claude Code: no session found", - " Claude Code: could not be read", - " Claude Code: not covered", - " Claude Code: no session read belongs to it", - ]); - }); - - it("carries a reason after the status, and a failed-session count after that", () => { - expect( - toolLine({ - status: "found", - recordsFound: 5, - recordsStored: 3, - reason: "one transcript was truncated", - sessionsFailed: 2, - failureReason: "EACCES", - }) - ).toBe( - " Claude Code: read (3 new of 5) — one transcript was truncated " + - "[2 sessions could not be read: EACCES]" - ); - }); - - it("counts one failed session in the singular", () => { - expect(toolLine({ status: "found", sessionsFailed: 1, failureReason: "EACCES" })).toBe( - " Claude Code: read (0 new of 0) [1 session could not be read: EACCES]" - ); - }); -}); - -describe("printLocalCostReadReport — the line the sweep leads with", () => { - it("counts the sessions it read and how many of them yielded a record", () => { - expect( - printed((output) => - printLocalCostReadReport( - output, - readResult({ - sessions: [ - { sessionId: "s-1", toolReports: [toolReport({ recordsFound: 2 })] }, - { sessionId: "s-2", toolReports: [toolReport({ recordsFound: 0 })] }, - ], - toolReports: [], - }) - ) - ) - ).toEqual([" 2 sessions read, 1 with records"]); - }); - - it("counts every session that yielded a record, not the ones that yielded none", () => { - expect( - printed((output) => - printLocalCostReadReport( - output, - readResult({ - sessions: [ - { sessionId: "s-1", toolReports: [toolReport({ recordsFound: 2 })] }, - { sessionId: "s-2", toolReports: [toolReport({ recordsFound: 4 })] }, - ], - toolReports: [], - }) - ) - ) - ).toEqual([" 2 sessions read, 2 with records"]); - }); - - it("counts one session in the singular", () => { - expect( - printed((output) => - printLocalCostReadReport( - output, - readResult({ sessions: [{ sessionId: "s-1", toolReports: [] }], toolReports: [] }) - ) - ) - ).toEqual([" 1 session read, 0 with records"]); - }); - - it("says nothing was journalled rather than that nothing was read", () => { - expect(printed((output) => printLocalCostReadReport(output, readResult()))).toEqual([ - " No session journalled yet — nothing to read.", - ]); - }); - - it("prints a refusal alone, never the journal's own count beside it", () => { - expect( - printed((output) => - printLocalCostReadReport( - output, - readResult({ - refusedReason: "measurement is off for this project", - sessions: [{ sessionId: "s-1", toolReports: [] }], - toolReports: [toolReport({})], - }) - ) - ) - ).toEqual([" measurement is off for this project"]); - }); -}); - -describe("printPersonIdentityStatus", () => { - it("says off, in the words the switch beside it never uses", () => { - expect( - printed((output) => - printPersonIdentityStatus(output, { filePath: IDENTITY_FILE, identity: null }) - ) - ).toEqual(["AIDD identity: off - records carry no person"]); - }); - - it("names the identifier, where it came from and the file holding it", () => { - expect( - printed((output) => - printPersonIdentityStatus(output, { - filePath: IDENTITY_FILE, - identity: { personId: "p-1", origin: "minted", alsoMe: [] }, - }) - ) - ).toEqual([`AIDD identity: on, p-1 (minted on this machine) (${IDENTITY_FILE})`]); - }); - - it("quotes a display name between the origin and the file, and lists the added identifiers", () => { - expect( - printed((output) => - printPersonIdentityStatus(output, { - filePath: IDENTITY_FILE, - identity: { - personId: "p-1", - origin: "adopted", - alsoMe: ["machine-1", "machine-2"], - displayName: "Ada", - }, - }) - ) - ).toEqual([ - `AIDD identity: on, p-1 (taken from another machine), display name "Ada" (${IDENTITY_FILE})`, - " Identifiers added onto this person: machine-1, machine-2", - ]); - }); -}); - -describe("printPersonIdentityUse", () => { - it("discloses what a minted identifier attaches to, and what it never attaches to", () => { - expect( - printed((output) => - printPersonIdentityUse(output, { - filePath: IDENTITY_FILE, - identity: { personId: "p-1", origin: "minted", alsoMe: [] }, - outcome: "minted", - }) - ) - ).toEqual([ - `AIDD identity: on, p-1 (${IDENTITY_FILE})`, - " Attaches to: records this machine reads locally, from now on.", - " Never attaches to: the run journal, a session already recorded, or a tool's own export.", - ]); - }); - - it("names what an adopted identifier replaced, and what the old records keep", () => { - expect( - printed((output) => - printPersonIdentityUse(output, { - filePath: IDENTITY_FILE, - identity: { personId: "p-2", origin: "adopted", alsoMe: [] }, - outcome: "adopted", - replacedPersonId: "p-1", - }) - ) - ).toEqual([ - `AIDD identity: now p-2 (replacing p-1) (${IDENTITY_FILE})`, - " Records already written keep the identifier they were written with.", - DISCLAIMER, - ]); - }); - - it("says nothing about replacing when the identifier taken was nobody's before", () => { - expect( - printed((output) => - printPersonIdentityUse(output, { - filePath: IDENTITY_FILE, - identity: { personId: "p-2", origin: "adopted", alsoMe: [] }, - outcome: "adopted", - }) - ) - ).toEqual([`AIDD identity: now p-2 (${IDENTITY_FILE})`, DISCLAIMER]); - }); - - it("claims nothing was written for an unchanged identifier with no name alongside", () => { - expect( - printed((output) => - printPersonIdentityUse(output, { - filePath: IDENTITY_FILE, - identity: { personId: "p-1", origin: "minted", alsoMe: [] }, - outcome: "unchanged", - }) - ) - ).toEqual([`AIDD identity: p-1 already in effect (${IDENTITY_FILE})`]); - }); - - it("says a display name was set on the same line, then prints it", () => { - expect( - printed((output) => - printPersonIdentityUse(output, { - filePath: IDENTITY_FILE, - identity: { personId: "p-1", origin: "minted", alsoMe: [], displayName: "Ada" }, - outcome: "unchanged", - displayNameSet: "Ada", - }) - ) - ).toEqual([ - `AIDD identity: p-1 already in effect, display name set (${IDENTITY_FILE})`, - " Display name: Ada", - ]); - }); -}); - -describe("printPersonIdentityOff", () => { - it("says already off without claiming a file was removed", () => { - expect( - printed((output) => - printPersonIdentityOff(output, { - filePath: IDENTITY_FILE, - removed: false, - discardedDamaged: false, - addedIdentifiersRemoved: 0, - }) - ) - ).toEqual(["AIDD identity: already off - nothing to withdraw"]); - }); - - it("names the file, what stays, that opting in again mints afresh, and the count removed", () => { - expect( - printed((output) => - printPersonIdentityOff(output, { - filePath: IDENTITY_FILE, - removed: true, - discardedDamaged: false, - addedIdentifiersRemoved: 2, - }) - ) - ).toEqual([ - `AIDD identity: off (${IDENTITY_FILE} removed)`, - " New records carry no person, from now on.", - " Records already stored keep the identifier they were written with - none are changed.", - " Opting in again later mints a fresh identifier, never this one back.", - " 2 added identifiers removed with it.", - ]); - }); - - it("says a damaged file was discarded rather than read, and counts one in the singular", () => { - expect( - printed((output) => - printPersonIdentityOff(output, { - filePath: IDENTITY_FILE, - removed: true, - discardedDamaged: true, - addedIdentifiersRemoved: 1, - }) - ).filter((line) => line.includes("discarded") || line.includes("added identifier")) - ).toEqual([ - " The identity file could not be read, so it was discarded rather than left behind.", - " 1 added identifier removed with it.", - ]); - }); -}); - -describe("printPersonIdentityLink and printPersonIdentityUnlink", () => { - it("names the identifier, the person it joined and the file, then the disclaimer", () => { - expect( - printed((output) => - printPersonIdentityLink(output, { - filePath: IDENTITY_FILE, - personId: "p-1", - identity: "machine-2", - alreadyListed: false, - }) - ) - ).toEqual([`AIDD identity: linked 'machine-2' to p-1 (${IDENTITY_FILE})`, DISCLAIMER]); - }); - - it("says an identifier already listed is already listed, and adds no disclaimer", () => { - expect( - printed((output) => - printPersonIdentityLink(output, { - filePath: IDENTITY_FILE, - personId: "p-1", - identity: "machine-2", - alreadyListed: true, - }) - ) - ).toEqual([`AIDD identity: 'machine-2' is already listed under p-1 (${IDENTITY_FILE})`]); - }); - - it("names the identifier it withdrew, with the file it left", () => { - expect( - printed((output) => - printPersonIdentityUnlink(output, { - filePath: IDENTITY_FILE, - identity: "machine-2", - removed: true, - }) - ) - ).toEqual([`AIDD identity: unlinked 'machine-2' (${IDENTITY_FILE})`]); - }); - - it("says one nobody listed was never listed, never that removal failed", () => { - expect( - printed((output) => - printPersonIdentityUnlink(output, { - filePath: IDENTITY_FILE, - identity: "machine-9", - removed: false, - }) - ) - ).toEqual(["AIDD identity: 'machine-9' was not listed - nothing to remove"]); - }); -}); - -describe("warnIfFiguresMoveTheTokenToo", () => { - it("names the directory, the variable and the token it also moves, on stderr", () => { - const output = new CapturingOutput(); - const sink = new InMemoryTelemetrySink(); - sink.locatedBy = "user-config-dir"; - - warnIfFiguresMoveTheTokenToo(output, sink); - - expect(output.at("warn")).toEqual([ - "Figures are kept at /fake/telemetry, located through AIDD_USER_CONFIG_DIR — which also " + - "moves auth.json, this machine's GitHub token. If that directory is shared, the token " + - "is in it. Set AIDD_TELEMETRY_DIR to the same path instead: it moves the figures and " + - "nothing else.", - ]); - }); -}); diff --git a/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts b/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts deleted file mode 100644 index 897af2051..000000000 --- a/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { TelemetryRemovalPreview } from "../../../src/contexts/telemetry/domain/telemetry-removal.js"; -import { - printTelemetryForgetPreview, - printTelemetryForgetRefused, - printTelemetryForgetResult, -} from "../../../src/presentation/display/telemetry-forget-display.js"; -import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; - -function preview(overrides: Partial = {}): TelemetryRemovalPreview { - return { - journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, - sink: { scope: "machine", path: "/home/.config/aidd/telemetry", dayFileNames: [] }, - identity: { - scope: "machine", - path: "/home/.config/aidd/identity.json", - present: false, - unreadable: false, - }, - history: { certainty: "none" }, - ...overrides, - }; -} - -const NOTHING_MEASURED = - "AIDD telemetry: nothing was ever measured here — this project's journal, this machine's " + - "stored records and this machine's identity are all already empty. Nothing to remove."; - -const NO_REPOSITORY = - "This project is not a git repository, so no history holds this project's run journal."; - -function printedPreview(overrides: Partial = {}): string[] { - const output = new CapturingOutput(); - printTelemetryForgetPreview(output, preview(overrides)); - return output.lines; -} - -const ONE_RUN_FILE = { - scope: "project", - path: "/repo/aidd_docs/runs", - runFileNames: ["a.jsonl"], -} as const; - -describe("printTelemetryForgetPreview", () => { - it("says nothing was ever measured, and lists nothing, when every location is empty", () => { - expect(printedPreview()).toEqual([NOTHING_MEASURED, NO_REPOSITORY]); - }); - - it("names each location with its own path and count, machine scope spelled out", () => { - expect( - printedPreview({ - journal: { - scope: "project", - path: "/repo/aidd_docs/runs", - runFileNames: ["a.jsonl", "b.jsonl", "c.jsonl"], - }, - sink: { - scope: "machine", - path: "/home/.config/aidd/telemetry", - dayFileNames: ["2026-03-02.jsonl", "2026-03-03.jsonl"], - }, - }) - ).toEqual([ - "This would remove:", - " This project's run journal (/repo/aidd_docs/runs): 3 run file(s)", - " This machine's stored records — every project measured on this machine " + - "(/home/.config/aidd/telemetry): 2 day file(s)", - " This machine's identity (/home/.config/aidd/identity.json): nothing to remove", - NO_REPOSITORY, - ]); - }); - - it("counts a present identity as one file", () => { - expect( - printedPreview({ - journal: ONE_RUN_FILE, - identity: { - scope: "machine", - path: "/home/.config/aidd/identity.json", - present: true, - unreadable: false, - }, - })[3] - ).toBe(" This machine's identity (/home/.config/aidd/identity.json): 1 file"); - }); - - it("says a damaged identity file will still go, rather than reading as absent", () => { - expect( - printedPreview({ - journal: ONE_RUN_FILE, - identity: { - scope: "machine", - path: "/home/.config/aidd/identity.json", - present: true, - unreadable: true, - }, - })[3] - ).toBe( - " This machine's identity (/home/.config/aidd/identity.json): 1 file — present but " + - "could not be read; will still be removed" - ); - }); -}); - -describe("printTelemetryForgetPreview — what removal cannot reach", () => { - it("warns that a committed journal is certainly in history, and lists what is tracked", () => { - expect( - printedPreview({ - journal: ONE_RUN_FILE, - history: { - certainty: "committed", - files: ["aidd_docs/runs/a.jsonl", "aidd_docs/runs/b.jsonl"], - }, - }).at(-1) - ).toBe( - "Cannot be reached: this project's run journal has been committed, so git history " + - "certainly holds it. Tracked right now:\n" + - " aidd_docs/runs/a.jsonl\n aidd_docs/runs/b.jsonl\n" + - "Removing it from the working tree does not remove it from history. No command here " + - "rewrites git history." - ); - }); - - it("warns that a staged journal is not in history yet, and would come back on a commit", () => { - expect( - printedPreview({ - journal: ONE_RUN_FILE, - history: { certainty: "staged", files: ["aidd_docs/runs/a.jsonl"] }, - }).at(-1) - ).toBe( - "Cannot be reached, not yet: this project's run journal is staged (tracked by git " + - "right now) but has never been committed — history does not hold it yet:\n" + - " aidd_docs/runs/a.jsonl\n" + - "The staged copy stays in git's index after this removal deletes the working-tree " + - "file, so a later `git commit` with nothing further done would put it back. No " + - "command here touches git's index or history." - ); - }); - - it("warns that an untracked journal may still have been committed before", () => { - expect( - printedPreview({ journal: ONE_RUN_FILE, history: { certainty: "possible" } }).at(-1) - ).toBe( - "Cannot be reached: this project's run journal is not tracked by git right now, but " + - "history may still hold it if it was ever committed before — that cannot be told " + - "apart from never having been committed. No command here rewrites git history." - ); - }); - - it("puts every history reading on stderr, never on the results channel", () => { - const output = new CapturingOutput(); - - printTelemetryForgetPreview(output, preview()); - - expect(output.at("warn")).toEqual([NO_REPOSITORY]); - }); -}); - -describe("printTelemetryForgetRefused", () => { - // Looking and deciding not to is not an error, and the sentence has to name the flag that - // would have gone ahead. - it("reports nothing removed and names the flag, never a failure", () => { - const output = new CapturingOutput(); - - printTelemetryForgetRefused(output); - - expect(output.lines).toEqual([ - "Nothing removed. Pass --yes to remove exactly what is listed above.", - ]); - }); -}); - -describe("printTelemetryForgetResult", () => { - it("counts each location separately, so the three can be checked against the preview", () => { - const output = new CapturingOutput(); - - printTelemetryForgetResult(output, { - journal: { removed: 3, failed: [] }, - sink: { removed: 2, failed: [] }, - identity: { removed: 1, failed: [] }, - history: { certainty: "none" }, - }); - - expect(output.lines).toEqual([ - "AIDD telemetry: removed", - " This project's run journal: 3 removed", - " This machine's stored records: 2 removed", - " This machine's identity: 1 removed", - NO_REPOSITORY, - "The telemetry switch (.aidd/config.json) was not touched — measurement can be turned " + - "on again with `aidd telemetry on`.", - ]); - }); - - // One undeletable file must not read as everything having gone. - it("names every file it could not remove, under its own location's label", () => { - const output = new CapturingOutput(); - - printTelemetryForgetResult(output, { - journal: { removed: 1, failed: [{ path: "b.jsonl", reason: "EACCES" }] }, - sink: { removed: 0, failed: [{ path: "2026-03-02.jsonl", reason: "EPERM" }] }, - identity: { removed: 0, failed: [{ path: "identity.json", reason: "EBUSY" }] }, - history: { certainty: "none" }, - }); - - expect(output.lines.slice(1, 7)).toEqual([ - " This project's run journal: 1 removed, 1 could not be removed", - " This machine's stored records: 0 removed, 1 could not be removed", - " This machine's identity: 0 removed, 1 could not be removed", - "Could not remove journal run file b.jsonl — EACCES", - "Could not remove sink day file 2026-03-02.jsonl — EPERM", - "Could not remove identity file identity.json — EBUSY", - ]); - }); - - it("says only what was removed when nothing failed", () => { - const output = new CapturingOutput(); - - printTelemetryForgetResult(output, { - journal: { removed: 0, failed: [] }, - sink: { removed: 0, failed: [] }, - identity: { removed: 0, failed: [] }, - history: { certainty: "none" }, - }); - - expect(output.lines[1]).toBe(" This project's run journal: 0 removed"); - }); -}); diff --git a/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts b/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts index 1b540e71a..6c667e842 100644 --- a/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts +++ b/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts @@ -69,17 +69,6 @@ describe("FileAdapter, at the edges", () => { }); }); - describe("chmodExecutable()", () => { - it("makes the file executable", async () => { - const path = join(tempDir, "run.sh"); - await writeFile(path, "", { mode: 0o644 }); - - await fs.chmodExecutable(path); - - expect(await fs.isExecutable(path)).toBe(true); - }); - }); - describe("deleteEmptyDirectories()", () => { it("copes with a directory that is not there", async () => { await expect(fs.deleteEmptyDirectories(join(tempDir, "gone"))).resolves.toBeUndefined(); diff --git a/cli/tests/runtime/git/git-adapter.integration.test.ts b/cli/tests/runtime/git/git-adapter.integration.test.ts deleted file mode 100644 index 0ca84432d..000000000 --- a/cli/tests/runtime/git/git-adapter.integration.test.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_HOOK_HEADER, - SESSION_TRAILER_TOKEN, - sessionTrailerHookLine, -} from "../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; -import { FileAdapter } from "../../../src/runtime/filesystem/file-adapter.js"; -import { HasherAdapter } from "../../../src/runtime/filesystem/hasher-adapter.js"; -import { GitAdapter } from "../../../src/runtime/git/git-adapter.js"; -import { environmentWithoutGitVariables } from "../../../src/runtime/git/git-environment.js"; - -const DELEGATE = "aidd-session-trailer.sh"; -const SCRIPT = "#!/bin/sh\necho delegate\n"; -/** A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. */ -const MODE_BITS_UNOBSERVABLE = process.platform === "win32"; - -function git(cwd: string, ...args: string[]): string { - const result = spawnSync( - "git", - ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], - { cwd, encoding: "utf8", env: environmentWithoutGitVariables() } - ); - if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); - return result.stdout.trim(); -} - -async function commit(root: string, message: string): Promise { - await writeFile(join(root, `${Math.random()}.txt`), "x"); - git(root, "add", "."); - git(root, "commit", "-q", "-m", message); -} - -describe("GitAdapter", () => { - let root: string; - let outside: string; - let hooksDir: string; - let adapter: GitAdapter; - - beforeEach(async () => { - root = await realpath(await mkdtemp(join(tmpdir(), "aidd-git-adapter-"))); - outside = await realpath(await mkdtemp(join(tmpdir(), "aidd-git-adapter-outside-"))); - git(root, "init", "-q"); - hooksDir = join(root, ".git", "hooks"); - adapter = new GitAdapter(new FileAdapter(new HasherAdapter())); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - await rm(outside, { recursive: true, force: true }); - }); - - describe("isRepository", () => { - it("answers true inside a repository", async () => { - expect(await adapter.isRepository(root)).toBe(true); - }); - - it("answers false outside one", async () => { - expect(await adapter.isRepository(outside)).toBe(false); - }); - }); - - describe("listTrackedFiles", () => { - it("lists the tracked paths matching the pathspec, relative to the root", async () => { - await mkdir(join(root, "aidd_docs", "runs"), { recursive: true }); - await writeFile(join(root, "aidd_docs", "runs", "a.jsonl"), ""); - await writeFile(join(root, "other.txt"), ""); - git(root, "add", "."); - - expect(await adapter.listTrackedFiles(root, "aidd_docs/runs/")).toStrictEqual([ - "aidd_docs/runs/a.jsonl", - ]); - }); - - it("answers nothing outside a repository", async () => { - expect(await adapter.listTrackedFiles(outside, "aidd_docs/runs/")).toStrictEqual([]); - }); - }); - - describe("hasHistoryFor", () => { - it("is false while the pathspec is only staged", async () => { - await writeFile(join(root, "tracked.txt"), ""); - git(root, "add", "."); - - expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(false); - }); - - it("is false for a pathspec staged after other commits", async () => { - await commit(root, "one"); - await writeFile(join(root, "tracked.txt"), ""); - git(root, "add", "."); - - expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(false); - }); - - it("is true once a commit touched the pathspec", async () => { - await writeFile(join(root, "tracked.txt"), ""); - git(root, "add", "."); - git(root, "commit", "-q", "-m", "one"); - - expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(true); - }); - - it("is false outside a repository", async () => { - expect(await adapter.hasHistoryFor(outside, "tracked.txt")).toBe(false); - }); - }); - - describe("installCommitMessageDelegate, when this CLI owns the hook", () => { - it("writes the delegate executable and a hook calling it from scratch", async () => { - const result = await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - const delegatePath = join(hooksDir, DELEGATE); - expect(result).toStrictEqual({ lineAdded: true }); - expect(await readFile(delegatePath, "utf8")).toBe(SCRIPT); - expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( - `${SESSION_TRAILER_HOOK_HEADER}\n${sessionTrailerHookLine(delegatePath)}\n` - ); - }); - - it("reports the delegate executable and the call site present afterwards", async () => { - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - expect( - await adapter.readCommitTrailerSetup(root, DELEGATE, SESSION_TRAILER_TOKEN, 10) - ).toStrictEqual({ - delegate: "executable", - hookExecutable: true, - callSite: "present", - hookHasOtherContent: false, - hooksDir, - }); - }); - - it("adds nothing the second time", async () => { - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ - lineAdded: false, - }); - }); - - it("appends one line to an existing hook, on its own line", async () => { - await mkdir(hooksDir, { recursive: true }); - await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\necho mine"); - - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( - `#!/bin/sh\necho mine\n${sessionTrailerHookLine(join(hooksDir, DELEGATE))}\n` - ); - }); - - it("follows core.hooksPath rather than assuming .git/hooks", async () => { - const custom = join(root, "my-hooks"); - git(root, "config", "core.hooksPath", "my-hooks"); - - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - expect(await readFile(join(custom, DELEGATE), "utf8")).toBe(SCRIPT); - }); - - it("installs nothing outside a repository", async () => { - expect(await adapter.installCommitMessageDelegate(outside, DELEGATE, SCRIPT)).toStrictEqual({ - lineAdded: false, - }); - }); - }); - - describe("installCommitMessageDelegate, when a manager owns the hook", () => { - it("lands the delegate in the common hooks directory and appends no line", async () => { - await writeFile(join(root, "lefthook.yml"), "pre-commit:\n"); - - const result = await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - expect(result).toStrictEqual({ - lineAdded: false, - hookManager: "lefthook", - managerCallsDelegate: false, - }); - expect(await readFile(join(hooksDir, DELEGATE), "utf8")).toBe(SCRIPT); - await expect(readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).rejects.toThrow( - /ENOENT/ - ); - }); - - it("reads a lefthook config that names the delegate as already wired", async () => { - await writeFile(join(root, ".lefthook.yaml"), `run: sh .git/hooks/${DELEGATE}\n`); - - expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ - lineAdded: false, - hookManager: "lefthook", - managerCallsDelegate: true, - }); - }); - - it("reads husky's own prepare-commit-msg for the delegate", async () => { - await mkdir(join(root, ".husky"), { recursive: true }); - await writeFile(join(root, ".husky", "prepare-commit-msg"), `sh ${DELEGATE}\n`); - - expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ - lineAdded: false, - hookManager: "husky", - managerCallsDelegate: true, - }); - }); - - it("reads a husky directory holding no prepare-commit-msg as not wired", async () => { - await mkdir(join(root, ".husky"), { recursive: true }); - - expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ - lineAdded: false, - hookManager: "husky", - managerCallsDelegate: false, - }); - }); - - it("ignores core.hooksPath under a manager", async () => { - await writeFile(join(root, "lefthook.yml"), ""); - git(root, "config", "core.hooksPath", "my-hooks"); - - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - expect(await readFile(join(hooksDir, DELEGATE), "utf8")).toBe(SCRIPT); - }); - - it("installs nothing outside a repository, and names no manager", async () => { - await writeFile(join(outside, "lefthook.yml"), ""); - - expect(await adapter.installCommitMessageDelegate(outside, DELEGATE, SCRIPT)).toStrictEqual({ - lineAdded: false, - }); - }); - }); - - describe("removeCommitMessageDelegate", () => { - it("drops the one line and deletes the delegate, leaving the rest of the hook", async () => { - await mkdir(hooksDir, { recursive: true }); - await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\necho mine\n"); - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - const result = await adapter.removeCommitMessageDelegate(root, DELEGATE); - - expect(result).toStrictEqual({ removed: true }); - expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( - "#!/bin/sh\necho mine\n" - ); - expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ - delegate: "absent", - hookExecutable: true, - callSite: "missing", - hookHasOtherContent: true, - hooksDir, - }); - }); - - it("reports nothing removed when nothing was installed", async () => { - expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ - removed: false, - }); - }); - - it("reports nothing removed from a hook that never called the delegate", async () => { - await mkdir(hooksDir, { recursive: true }); - await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\necho mine\n"); - - expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ - removed: false, - }); - expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( - "#!/bin/sh\necho mine\n" - ); - }); - - it("drops the line even when a hand edit indented it", async () => { - await mkdir(hooksDir, { recursive: true }); - const line = sessionTrailerHookLine(join(hooksDir, DELEGATE)); - await writeFile(join(hooksDir, "prepare-commit-msg"), `#!/bin/sh\n ${line}\necho mine\n`); - - expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ - removed: true, - }); - expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( - "#!/bin/sh\necho mine\n" - ); - }); - it.skipIf(MODE_BITS_UNOBSERVABLE)( - "leaves a hook that was not executable as it found it", - async () => { - await mkdir(hooksDir, { recursive: true }); - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - const hookPath = join(hooksDir, "prepare-commit-msg"); - await writeFile( - hookPath, - `#!/bin/sh\n${sessionTrailerHookLine(join(hooksDir, DELEGATE))}\n` - ); - await chmod(hookPath, 0o644); - - await adapter.removeCommitMessageDelegate(root, DELEGATE); - - expect((await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).hookExecutable).toBe( - false - ); - } - ); - - it("counts a hand-deleted delegate whose line is still there as removed", async () => { - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - await rm(join(hooksDir, DELEGATE)); - - expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ - removed: true, - }); - }); - - it("counts a delegate whose line was hand-dropped as removed", async () => { - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n"); - - expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ - removed: true, - }); - }); - - it("looks under the manager's directory and carries the manager facts", async () => { - await writeFile(join(root, "lefthook.yml"), ""); - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ - removed: true, - hookManager: "lefthook", - managerCallsDelegate: false, - }); - }); - - it("carries the manager facts outside a repository too", async () => { - await writeFile(join(outside, "lefthook.yml"), ""); - - expect(await adapter.removeCommitMessageDelegate(outside, DELEGATE)).toStrictEqual({ - removed: false, - hookManager: "lefthook", - managerCallsDelegate: false, - }); - }); - }); - - describe("readCommitTrailerSetup", () => { - it("says no hook file and no delegate on a fresh repository", async () => { - expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDir, - }); - }); - - it("says no repository outside one", async () => { - expect(await adapter.readCommitTrailerSetup(outside, DELEGATE, "X", 1)).toStrictEqual({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: "no-repository", - }); - }); - it.skipIf(MODE_BITS_UNOBSERVABLE)("reports a hook git would refuse to run", async () => { - await mkdir(hooksDir, { recursive: true }); - await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n", { mode: 0o644 }); - - expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ - delegate: "absent", - hookExecutable: false, - callSite: "missing", - hookHasOtherContent: false, - hooksDir, - }); - }); - it.skipIf(MODE_BITS_UNOBSERVABLE)( - "tells a delegate that is there but not executable from one that is missing", - async () => { - await mkdir(hooksDir, { recursive: true }); - await writeFile(join(hooksDir, DELEGATE), SCRIPT, { mode: 0o644 }); - - expect((await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).delegate).toBe( - "not-executable" - ); - } - ); - - it("does not count the header the CLI writes as somebody else's content", async () => { - await mkdir(hooksDir, { recursive: true }); - await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n\n \n"); - - expect( - (await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).hookHasOtherContent - ).toBe(false); - }); - - it("counts, among the last commits, those carrying the trailer", async () => { - await commit(root, "plain"); - await commit(root, `stamped\n\n${SESSION_TRAILER_TOKEN}: abc`); - await commit(root, "plain again"); - - expect( - (await adapter.readCommitTrailerSetup(root, DELEGATE, SESSION_TRAILER_TOKEN, 2)) - .recentlyCarrying - ).toStrictEqual({ carrying: 1, examined: 2 }); - }); - - it("leaves the count absent, not zero, when there is no history", async () => { - expect( - "recentlyCarrying" in (await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 5)) - ).toBe(false); - }); - - it("reports the delegate under a manager's directory even when the hooks dir is elsewhere", async () => { - await writeFile(join(root, "lefthook.yml"), `pre-commit: ${DELEGATE}`); - git(root, "config", "core.hooksPath", "my-hooks"); - await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); - - expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ - delegate: "executable", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDir: join(root, "my-hooks"), - hookManager: "lefthook", - managerCallsDelegate: true, - }); - }); - }); -}); diff --git a/cli/tests/runtime/self-update/check-update-use-case.unit.test.ts b/cli/tests/runtime/self-update/check-update-use-case.unit.test.ts index 53284dba4..aeec006d8 100644 --- a/cli/tests/runtime/self-update/check-update-use-case.unit.test.ts +++ b/cli/tests/runtime/self-update/check-update-use-case.unit.test.ts @@ -55,7 +55,6 @@ function makeFsStub(store: Map = new Map()): FileReader & FileWr deleteDirectory: async () => {}, isExecutable: async () => false, realpath: async (path: string) => path, - chmodExecutable: async () => {}, }; } diff --git a/cli/tests/runtime/self-update/check-update.unit.test.ts b/cli/tests/runtime/self-update/check-update.unit.test.ts index 28b71a15d..fbc82f2ef 100644 --- a/cli/tests/runtime/self-update/check-update.unit.test.ts +++ b/cli/tests/runtime/self-update/check-update.unit.test.ts @@ -54,7 +54,6 @@ function makeFsStub(store: Map = new Map()): FileReader & FileWr deleteDirectory: async () => {}, isExecutable: async () => false, realpath: async (path: string) => path, - chmodExecutable: async () => {}, }; } diff --git a/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts b/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts deleted file mode 100644 index 699227dc9..000000000 --- a/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import { describe, expect, it } from "vitest"; -import { Manifest } from "../../../src/contexts/framework/domain/manifest.js"; -import { InstalledPlugin } from "../../../src/contexts/framework/domain/plugins/installed-plugin.js"; -import type { ManifestRepository } from "../../../src/contexts/framework/domain/ports/manifest-repository.js"; -import { installedPluginsFromManifest } from "../../../src/runtime/wiring/installed-plugins-from-manifest.js"; - -function repo(manifest: Manifest | null): ManifestRepository { - return { - path: "/proj/.aidd/manifest.json", - load: async () => manifest, - save: async () => {}, - delete: async () => {}, - }; -} - -describe("installedPluginsFromManifest", () => { - it("answers nothing at all when there is no manifest", async () => { - expect(await installedPluginsFromManifest(repo(null)).read()).toBeNull(); - }); - - it("lists only the tools that hold a plugin, by name and marketplace", async () => { - const manifest = Manifest.create(); - manifest.addTool("claude", "1.0.0", []); - manifest.addPlugin( - "claude", - InstalledPlugin.fromJSON({ - name: "aidd-dev", - source: { kind: "local", path: "/p" }, - version: "1.0.0", - strict: false, - files: {}, - scope: "project", - marketplace: "aidd-framework", - }) - ); - - const byTool = await installedPluginsFromManifest(repo(manifest)).read(); - - expect([...(byTool ?? new Map()).entries()]).toStrictEqual([ - ["claude", [{ name: "aidd-dev", marketplace: "aidd-framework" }]], - ]); - }); - - it("names the manifest file it reads", () => { - expect(installedPluginsFromManifest(repo(null)).path).toBe("/proj/.aidd/manifest.json"); - }); -}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index abe4c6e43..c03ec29c0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -78,13 +78,7 @@ Place each capability in its owning concern and delegate to it. ### Measurement boundary -| Concern | Contract | -| --- | --- | -| Consent | Committed `.aidd/config.json` with `telemetry.enabled: true`; a directory grants no permission. | -| Writing | Git-ignored, append-only session observations. | -| Reading | Derive task identity and join provider measurements. | - -See the [journal contract](../aidd_docs/runs/README.md). +Measurement is being rebuilt on a deterministic design; its contract lands with the replacement. Until then `aidd-telemetry` ships no hook and no skill. ## Execution model @@ -121,11 +115,6 @@ Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on ` | Plugin | Event | Runs | Purpose | | ---------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------- | | `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | -| `aidd-telemetry` | `SessionStart` · `Stop` · `PostToolUse` | `hooks/journal.cjs` | Journal every session so a unit of work can be tied to its cost | - -### CLI queries - -Queries and reports share one CLI implementation to avoid duplicated logic. CLI-backed skills must explain a missing `aidd`; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) enforces this. ## Portability @@ -134,7 +123,7 @@ Queries and reports share one CLI implementation to avoid duplicated logic. CLI- | CLI output | Supported target capabilities only; `aidd translate` warns and skips rules and commands. | [Output layouts](../cli/README.md#translate) | | Skills | Links stay inside the skill directory. Flat distribution renames skills `-`; marketplace installation preserves the tree. | [Portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) | | Bundled scripts | Named plugin-relative in backticks, never linked. | [Portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) | -| Hook adapters | CLI owns OpenCode's shared host protocol; plugins own payload mapping. | [Delivery and compatibility](../cli/ARCHITECTURE.md#hook-adaptation); [measurement coverage](../plugins/aidd-telemetry/README.md#coverage) | +| Hook adapters | CLI owns OpenCode's shared host protocol; plugins own payload mapping. | [Delivery and compatibility](../cli/ARCHITECTURE.md#hook-adaptation) | ## Capability discovery and addressing diff --git a/docs/CATALOG.md b/docs/CATALOG.md index c169c926b..f0b7b8ae9 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -9,7 +9,7 @@ The exhaustive list of AIDD plugins, skills, and actions. Skills are invoked thr - [aidd-vcs](#-aidd-vcs) - version control workflows - [aidd-orchestrator](#-aidd-orchestrator) - async orchestration (optional) - [aidd-ui](#-aidd-ui) - UI / UX (🚧 alpha, not ready) -- [aidd-telemetry](#-aidd-telemetry) - measurement, hooks and skills (🧪 beta, off the curated path) +- [aidd-telemetry](#-aidd-telemetry) - measurement, being rebuilt (🧪 beta, off the curated path) - [aidd-qa](#-aidd-qa) - acceptance QA (🆕 new, off the curated path) --- @@ -111,16 +111,7 @@ Runs synchronous feature delivery, optional async issue automation, and the prod ## 📈 aidd-telemetry -🧪 **Beta — usable, and being proven.** Measurement: bundled hooks journal every session so a unit of work can be tied to what it cost, and three skills turn that on, read it back, and check it is actually recording. Off the curated install path while it is tested in the open; every tool's coverage and every known gap is named in the plugin's own README. - -Recording needs nothing installed; turning it on and reading it back both need the `aidd` -CLI, and each skill says so before doing anything else if it is missing. - -| Skill | Role | Actions | -| ---------- | -------------------------------------------------------------- | --------------------------------- | -| `00-init` | Turn measurement on for a project and prove it is recording | `01-check`, `02-enable`, `03-verify` | -| `01-cost` | Answer what a period or one task cost, by step, model and tool | `01-locate`, `02-collect`, `03-report` | -| `02-check` | Answer whether measurement is actually recording, line by line | `01-locate`, `02-diagnose` | +🧪 **Beta — being rebuilt.** Measurement: the previous version is removed; this plugin ships no hook and no skill until the replacement lands. ## 🎬 aidd-qa diff --git a/docs/FAQ.md b/docs/FAQ.md index f7e4ae3e8..cb83178ba 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -42,34 +42,11 @@ You can write your own Claude Code skills — nothing stops you. AIDD exists bec - **Authored for Claude Code.** Other tools install via their native mechanism from the release archives ([Other tools](../README.md#other-tools)); public-marketplace publishing is on the way, native parity is a roadmap item. - **Plugins assume their own context.** A skill that expects a git repo, a `package.json`, or a ticketing tool won't work without it — check the plugin's README. - **No hosted service.** AIDD is prompt content you install into your own tool; there is no AIDD server and no account. -- **Measurement is off unless you turn it on.** It records nothing until you do, and nothing leaves your machine → [Measurement](#-measurement). +- **Measurement is being rebuilt.** The previous version is removed and nothing is recorded today → [Measurement](#-measurement). ## 📊 Measurement -**The switch is git-tracked, so it applies to everyone who clones; opt out per person with `AIDD_TELEMETRY=0`. Nothing leaves your machine.** - -The `aidd-telemetry` plugin is not part of the curated install, and even installed it records -nothing until you allow it. Ask your AI tool for the plugin's `00-init` skill: it turns -measurement on for the current project and tells you what is now recorded. - -Then work as usual, and ask `01-cost` what a period or a task consumed. No account, no -server — but turning it on and asking both need the `aidd` CLI -(`npm install -g @ai-driven-dev/cli`); recording itself, in between, needs nothing -installed. - -| Where | What | -| --- | --- | -| `aidd_docs/runs/` in your repository, git-ignored | which session served which task, which skill was running when, and which files inside a task folder changed | -| `~/.config/aidd/telemetry/` | token counts and model names, read out of the transcript your AI tool already wrote | - -**No prompt, no code, no diff** — the stored shape is an allowlist, field by field, in -[`metrics-contract.md`](../aidd_docs/product/metrics-contract.md). - -Turning it off stops the recording and keeps what you already measured. `aidd telemetry off` -removes the switch and the commit trailer, `aidd telemetry forget` the records; both -directories, and the `.aidd/config.json` that opted you in, are ordinary files you can delete. Coverage differs per AI tool, and a tool that cannot be -measured is named rather than shown as a zero → -[the plugin's README](../plugins/aidd-telemetry/README.md). +**Nothing is measured today.** The previous measurement is removed and is being rebuilt on a deterministic design, for Claude Code first. Nothing leaves your machine meanwhile: the `aidd-telemetry` plugin ships no hook and no skill, and the plugin's [README](../plugins/aidd-telemetry/README.md) says where the rebuild stands. ## 🆘 Still stuck? diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index 982af57d2..cd6247fb8 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -141,7 +141,6 @@ Roles, promotion, and inactivity rules → [`GOVERNANCE.md`](../GOVERNANCE.md#-r - **README counts** — the hero `N plugins · N skills · N agents` block (between the `counts:start`/`counts:end` markers) and each per-plugin `N skills` span — auto-generated by `scripts/sync-readme-counts.mjs` via lefthook. - **Per-plugin `CATALOG.md`** — auto-generated by `scripts/summarize-markdown.js` via lefthook. - **README contributors mosaic** — the contrib.rocks image updates itself. -- **`docs/prompts-documentation.md`** — auto-generated by `scripts/summarize-markdown.js` via lefthook. ## 🛠️ Multi-tool diff --git a/docs/prompts-documentation.md b/docs/prompts-documentation.md deleted file mode 100644 index 41a63dc26..000000000 --- a/docs/prompts-documentation.md +++ /dev/null @@ -1,50 +0,0 @@ -# AIDD Framework Catalog - -Auto-generated framework content: agents, commands, rules, skills, and templates. - -> This file is automatically updated by the `scripts/summarize-markdown.js` script. - -## Table of Contents - -- [`.claude-plugin`](#claude-plugin) -- [`hooks`](#hooks) -- [`skills`](#skills) - - [`skills/00-init`](#skills00-init) - - [`skills/01-cost`](#skills01-cost) - - [`skills/02-check`](#skills02-check) - ---- - -### `.claude-plugin` - -| File | -|------| -| [plugin.json](../plugins/aidd-telemetry/.claude-plugin/plugin.json) | - -### `hooks` - -| File | -|------| -| [hooks.json](../plugins/aidd-telemetry/hooks/hooks.json) | -| [opencode-plugin.js](../plugins/aidd-telemetry/hooks/opencode-plugin.js) | - -### `skills` - -#### `skills/00-init` - -| File | Description | -|------|---| -| [SKILL.md](../plugins/aidd-telemetry/skills/00-init/SKILL.md) | `Turns AIDD measurement on for a project, proves it is recording, lets a person opt into (or out of) naming themselves on their own records, and removes what was measured when asked. Use when the user wants to start measuring what their work costs, wants to stop, asks why nothing is being recorded, wants their own name to appear on (or disappear from) what gets measured, or wants their measured data deleted. Not for answering what a piece of work consumed.` | - -#### `skills/01-cost` - -| File | Description | -|------|---| -| [SKILL.md](../plugins/aidd-telemetry/skills/01-cost/SKILL.md) | `Answers what a period or one task consumed - a total, a day-by-day series, or a breakdown by step, model, task, backlog item, orchestrated flow, tool, project or person - and hands back the artefact each question deserves. Use when the user asks what a piece of work cost, what changed, where the effort went, which task, which backlog item or ticket, which orchestrated run, for which project, or who spent it. Not for turning measurement on.` | - -#### `skills/02-check` - -| File | Description | -|------|---| -| [SKILL.md](../plugins/aidd-telemetry/skills/02-check/SKILL.md) | `States what is in place — where measurement is allowed from, whether an identity is attached, where records land, whether the recorder is declared — then answers whether AIDD measurement is actually recording, one independently verifiable line per claim. Use when the user doubts a figure, sees no run file appear, wants proof the chain is working, or wants to know what is already configured. Not for turning measurement on or answering what a period cost.` | - diff --git a/lefthook.yml b/lefthook.yml index 00d136edb..ca9c589e2 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -138,17 +138,6 @@ pre-commit: >/dev/null git add "${plugin}CATALOG.md" 2>/dev/null || true done - summarize-telemetry-prompts-doc: - run: | - if ! command -v node >/dev/null 2>&1; then - echo "ℹ️ node not available; skipping summarize-telemetry-prompts-doc" - exit 0 - fi - node scripts/summarize-markdown.js plugins/aidd-telemetry docs/prompts-documentation.md \ - --depth=3 \ - --fields=description \ - >/dev/null - git add docs/prompts-documentation.md 2>/dev/null || true sync-readme-counts: run: | if ! command -v node >/dev/null 2>&1; then @@ -192,12 +181,3 @@ commit-msg: commands: commitlint: run: pnpm exec commitlint --edit {1} - -# lefthook owns prepare-commit-msg here and regenerates it on install, so `aidd telemetry on`'s -# delegate is called from a job instead. Absent delegate, nothing runs — opt-in per machine. -prepare-commit-msg: - commands: - aidd-session-trailer: - run: | - delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh" - if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi diff --git a/package.json b/package.json index c6149fe1f..35465fb46 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,7 @@ }, "scripts": { "prepare": "lefthook install || true", - "test:changed": "node scripts/test-changed.mjs", - "telemetry:reference-week": "node scripts/telemetry-reference-week.cjs" + "test:changed": "node scripts/test-changed.mjs" }, "devDependencies": { "@commitlint/cli": "^21.2.3", diff --git a/plugins/aidd-dev/skills/01-plan/SKILL.md b/plugins/aidd-dev/skills/01-plan/SKILL.md index 1bbb438ae..e8649e4a6 100644 --- a/plugins/aidd-dev/skills/01-plan/SKILL.md +++ b/plugins/aidd-dev/skills/01-plan/SKILL.md @@ -20,18 +20,6 @@ Turn a gathered source into an implementation plan and its phase files. Never wr Run them in order, `01 → 04`. The plan is the culmination. Skip `03` when there is no UI. Before running an action, read its file in `actions/`, not only the table or assets. -Once the plan and its phase files are written, and only then, run: - -```shell -echo "aidd:step-end aidd-dev:01-plan" -``` - -This is the one thing about a step nothing else can supply. No host reports when a skill's -work finished — a skill call's own result comes back in a tenth of a second, which is the -dispatch, not the completion — so a measurement that never hears this ends the step at the -next pause and credits planning with one turn out of however many it took. The framework -repository documents the run journal this writes into, in `aidd_docs/runs/README.md`. - ## References - `references/wireframe-conventions.md`: how to draw the ASCII wireframe a screen needs. diff --git a/plugins/aidd-dev/skills/01-plan/actions/04-plan.md b/plugins/aidd-dev/skills/01-plan/actions/04-plan.md index 0803da0ff..e6f0677ce 100644 --- a/plugins/aidd-dev/skills/01-plan/actions/04-plan.md +++ b/plugins/aidd-dev/skills/01-plan/actions/04-plan.md @@ -13,17 +13,7 @@ A feature folder, always at `aidd_docs/tasks//_/_/_/`, reusing this feature's folder when one exists. -6. **Declare.** Declare the request's backlog item per [backlog-link.md](../references/backlog-link.md). -7. **Return.** Its path and its gaps. +6. **Return.** Its path and its gaps. ## Test @@ -28,4 +27,3 @@ The path to `spec.md`, or no write. | The spec is read back | it carries no library name, framework pattern, or source-file layout | | A gap exists | it is listed once, in the open questions section only | | Too vague | no write; one clarifying question returned | -| A backlog item is named, or none is, or the file is already there | each handled per [backlog-link.md](../references/backlog-link.md) | diff --git a/plugins/aidd-pm/skills/04-spec/assets/backlog-link-template.json b/plugins/aidd-pm/skills/04-spec/assets/backlog-link-template.json deleted file mode 100644 index 3fd848d37..000000000 --- a/plugins/aidd-pm/skills/04-spec/assets/backlog-link-template.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "backlog": "owner/repo#123", - "written_at": "2026-08-21T09:00:00Z", - "written_by": "aidd-pm:04-spec" -} diff --git a/plugins/aidd-pm/skills/04-spec/references/backlog-link.md b/plugins/aidd-pm/skills/04-spec/references/backlog-link.md deleted file mode 100644 index 24b966ffa..000000000 --- a/plugins/aidd-pm/skills/04-spec/references/backlog-link.md +++ /dev/null @@ -1,12 +0,0 @@ -# Backlog link - -A backlog item is a ticket already resolved, or a path to its Markdown artefact. Never raw text. - -Declare it in the feature folder as `backlog-link.json`, from -[backlog-link-template.json](../assets/backlog-link-template.json). - -`backlog` is the one field: a forge reference or a project-relative Markdown path, never both. -`written_at` is now, ISO 8601 UTC. `written_by` is the skill that wrote it. - -No item named, no file: an undeclared folder is normal. -Never overwrite one, so a correction by hand survives. diff --git a/plugins/aidd-telemetry/.claude-plugin/plugin.json b/plugins/aidd-telemetry/.claude-plugin/plugin.json index b9881f96c..ba537dd7f 100644 --- a/plugins/aidd-telemetry/.claude-plugin/plugin.json +++ b/plugins/aidd-telemetry/.claude-plugin/plugin.json @@ -2,16 +2,14 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "aidd-telemetry", "version": "0.2.1", - "description": "Measurement. The hooks journal which skill served which task, on plain node with nothing installed; three skills reach the `aidd` CLI to turn it on, answer what a period or a task consumed, and check it is recording. Off unless you turn it on, and it computes no amount.", + "description": "Measurement, being rebuilt. The previous version is removed; this plugin ships no hook and no skill until the replacement lands.", "author": { "name": "AI-Driven Dev", "url": "https://github.com/ai-driven-dev" }, "keywords": [ "telemetry", - "measurement", - "journal", - "hooks" + "measurement" ], "repository": "https://github.com/ai-driven-dev/framework", "homepage": "https://ai-driven.dev", diff --git a/plugins/aidd-telemetry/CATALOG.md b/plugins/aidd-telemetry/CATALOG.md index 5b1820ff5..63ff0904b 100644 --- a/plugins/aidd-telemetry/CATALOG.md +++ b/plugins/aidd-telemetry/CATALOG.md @@ -7,11 +7,6 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai ## Table of Contents - [`.claude-plugin`](#claude-plugin) -- [`hooks`](#hooks) -- [`skills`](#skills) - - [`skills/00-init`](#skills00-init) - - [`skills/01-cost`](#skills01-cost) - - [`skills/02-check`](#skills02-check) --- @@ -21,40 +16,3 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai |------| | [plugin.json](.claude-plugin/plugin.json) | -### `hooks` - -| File | -|------| -| [hooks.json](hooks/hooks.json) | -| [opencode-plugin.js](hooks/opencode-plugin.js) | - -### `skills` - -#### `skills/00-init` - -| Group | File | Description | -|-------|------|---| -| `actions` | [01-check.md](skills/00-init/actions/01-check.md) | - | -| `actions` | [02-enable.md](skills/00-init/actions/02-enable.md) | - | -| `actions` | [03-verify.md](skills/00-init/actions/03-verify.md) | - | -| `actions` | [04-identify.md](skills/00-init/actions/04-identify.md) | - | -| `actions` | [05-forget.md](skills/00-init/actions/05-forget.md) | - | -| `-` | [SKILL.md](skills/00-init/SKILL.md) | `Turns AIDD measurement on for a project, proves it is recording, lets a person opt into (or out of) naming themselves on their own records, and removes what was measured when asked. Use when the user wants to start measuring what their work costs, wants to stop, asks why nothing is being recorded, wants their own name to appear on (or disappear from) what gets measured, or wants their measured data deleted. Not for answering what a piece of work consumed.` | - -#### `skills/01-cost` - -| Group | File | Description | -|-------|------|---| -| `actions` | [01-locate.md](skills/01-cost/actions/01-locate.md) | - | -| `actions` | [02-collect.md](skills/01-cost/actions/02-collect.md) | - | -| `actions` | [03-report.md](skills/01-cost/actions/03-report.md) | - | -| `-` | [SKILL.md](skills/01-cost/SKILL.md) | `Answers what a period or one task consumed - a total, a day-by-day series, or a breakdown by step, model, task, backlog item, orchestrated flow, tool, project or person - and hands back the artefact each question deserves. Use when the user asks what a piece of work cost, what changed, where the effort went, which task, which backlog item or ticket, which orchestrated run, for which project, or who spent it. Not for turning measurement on.` | - -#### `skills/02-check` - -| Group | File | Description | -|-------|------|---| -| `actions` | [01-locate.md](skills/02-check/actions/01-locate.md) | - | -| `actions` | [02-diagnose.md](skills/02-check/actions/02-diagnose.md) | - | -| `-` | [SKILL.md](skills/02-check/SKILL.md) | `States what is in place — where measurement is allowed from, whether an identity is attached, where records land, whether the recorder is declared — then answers whether AIDD measurement is actually recording, one independently verifiable line per claim. Use when the user doubts a figure, sees no run file appear, wants proof the chain is working, or wants to know what is already configured. Not for turning measurement on or answering what a period cost.` | - diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index f0f351db7..04338db7f 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -4,101 +4,9 @@ Understand token usage by skill and task to improve workflows. -## Getting started - -Recording requires only `node`; enabling and reporting require `aidd`. - -```sh -npm install -g @ai-driven-dev/cli -aidd plugin install aidd-telemetry -``` - -Enable measurement, work in a session, then report and verify. Use skills or CLI commands; -skills stop and explain when `aidd` cannot answer. -Report after the turn: hooks fire before its tokens are durably written. - -| Ask your tool for | It runs | You get | -| --- | --- | --- | -| `00-init` | `aidd telemetry on`, then reads a run file back | project opt-in and recording proof | -| `01-cost` | `aidd telemetry report` | period or task usage | -| `02-check` | `aidd telemetry check` | recording status and required repairs | - -## Reading a report - -### Report construction - -AIDD connects provider counts to units of work. - -```mermaid -flowchart LR - Journal["Hook journal: work observed"] --> Report["aidd telemetry report: join by session"] - Transcript["Tool transcript: tokens and model"] --> Report - Report --> Results["Local usage by step, task, flow, model, tool or person"] -``` - -Tools write their own transcripts without AIDD skill knowledge. Reports join these with -hook observations by session. - -### Result interpretation - -Usage groups: step, model, task, flow, tool and person. Attribution states its evidence: - -- `stated by the tool`: exact attribution. -- `from a journal interval`: inferred attribution. -- `unattributed`: neither source identifies a step; it does not mean no step ran. -- **Unknown values** remain unknown, never zero. -- **Counts** are raw tokens, not currency; a separate service prices them. Vendor usage - screens weight cached tokens by price, so different counts do not mean either is wrong. -- **Periods** reflect work time, not billing time. Activity before opt-in cannot be reconstructed. +> The previous measurement is removed and is being rebuilt, for Claude Code first. This plugin +> ships no hook and no skill until the replacement lands. ## Coverage -> Beta. Proven end to end on Claude Code; other tools depend on their recorded data. -> Excluded from curated installation pending validation on other users' machines. - -| Tool | Tokens | Step | Task | -| --- | --- | --- | --- | -| **Claude Code** | ✅ proven on live sessions | ✅ stated by the tool, and by interval | ✅ | -| **Codex** | ✅ on captured rollouts | ✅ by interval | ✅ | -| **OpenCode** | ✅ | ❌ no skill call reaches its plugin | ✅ | -| **Copilot** | ⚠️ session total only, no per-request figure (cumulative at shutdown) | ✅ by interval | ✅ | -| **Cursor** | ❌ no token count in any file it writes | ✅ | ✅ | - -- **Codex:** approve each hook entry interactively. Headless runs cannot show the trust - prompt; sessions record nothing until approval. -- **OpenCode:** supports V1 ≥ 1.18.29 and V2; older V1 lacks the default plugin definition. - OpenCode V1 can omit the session announcement; the first journalable event then opens it. - Without a known session directory, it uses the plugin's startup directory, which can be - wrong for a server serving several projects. - V2 supplies session directories and journals tool and execution completions through its - event subscription. -- **OpenCode steps:** only task paths reach the journal, never skill calls. Every request - remains unattributed by step. - -## Data and privacy - -### Stored data - -Recording is local and opt-in, with no export, prompts, code or diffs. Hooks append one -line per observation to git-ignored `aidd_docs/runs/__.jsonl`, never -rewriting it or recording tokens, cost or model. Joined measurement records are stored under -`~/.config/aidd/telemetry/` according to [the record contract](../../aidd_docs/product/metrics-contract.md). - -### Privacy controls - -- **Project opt-in:** committing `.aidd/config.json` with measurement enabled affects all - clones. `AIDD_TELEMETRY=0` unconditionally overrides it for yourself. -- **Retention:** `off` keeps records. `aidd telemetry forget` removes the project's journal, - this machine's records and identity file; deletion requires `--yes`. -- **Legacy exports:** `aidd telemetry check` and `aidd telemetry off` detect old endpoints - and identify required manual removal. -- **Identity:** attach it optionally through `aidd telemetry identity`. Share figures using - `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`: the latter also relocates `auth.json` - and its GitHub token. - -## Reference contracts - -- [`aidd_docs/runs/README.md`](../../aidd_docs/runs/README.md): journal contract. -- [`cost-report-contract.md`](../../aidd_docs/product/cost-report-contract.md): the object - printed by `report --json`; optional `backlog-link.json` maps a task to its backlog item. -- [`metrics-contract.md`](../../aidd_docs/product/metrics-contract.md): stored records for pricing. +Restated when the rebuilt measurement lands. diff --git a/plugins/aidd-telemetry/hooks/hooks.json b/plugins/aidd-telemetry/hooks/hooks.json deleted file mode 100644 index f8f0328ac..000000000 --- a/plugins/aidd-telemetry/hooks/hooks.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.cjs session-start" - } - ] - } - ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.cjs turn-end" - } - ] - } - ], - "PostToolUse": [ - { - "hooks": [ - { - "type": "command", - "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.cjs tool-used" - } - ] - } - ] - } -} diff --git a/plugins/aidd-telemetry/hooks/journal.cjs b/plugins/aidd-telemetry/hooks/journal.cjs deleted file mode 100644 index 7167b184a..000000000 --- a/plugins/aidd-telemetry/hooks/journal.cjs +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env node -// Entry point for the run journal: read stdin, detect the host, dispatch by event, exit 0 -// no matter what. The work itself lives in hooks/lib/. - -const fs = require("node:fs"); - -const { detectHost, DECLARED_HOSTS } = require("./lib/host.cjs"); -const repo = require("./lib/repo.cjs"); -const record = require("./lib/record.cjs"); -const fileWrites = require("./lib/file-writes.cjs"); -const stepStarts = require("./lib/step-starts.cjs"); -const stepEnds = require("./lib/step-ends.cjs"); -const taskDeclared = require("./lib/task-declared.cjs"); - -function readStdin() { - try { - return fs.readFileSync(0, "utf8"); - } catch { - return ""; - } -} - -const CANONICAL_EVENTS = new Set(["session-start", "turn-end", "tool-used"]); - -// hook_event_name spellings observed per tool, consulted only as a fallback. -const HOOK_EVENT_NAME_TO_CANONICAL = Object.freeze({ - SessionStart: "session-start", - sessionStart: "session-start", // Cursor, Copilot - Stop: "turn-end", - stop: "turn-end", // Cursor - PostToolUse: "tool-used", - postToolUse: "tool-used", // Cursor, Copilot -}); - -// Argv carries the event name because Copilot's payload has none at all. -function resolveEventName(argvEvent, payload) { - if (CANONICAL_EVENTS.has(argvEvent)) return argvEvent; - return HOOK_EVENT_NAME_TO_CANONICAL[payload && payload.hook_event_name] || null; -} - -// Never tool-used, which fires on every tool call and would pay handleUnrecognisedPayload's -// git shellout once per call. Every declared host fires session-start at least once, so an -// undeclared one wired the same way costs no more than a declared one. -function maybeRecordUnrecognisedPayload(payload, event) { - if (!payload || typeof payload !== "object") return; - const resolvedEvent = resolveEventName(event, payload); - if (resolvedEvent !== "session-start" && resolvedEvent !== "turn-end") return; - record.handleUnrecognisedPayload(payload); -} - -function processPayload(payload, event) { - const host = detectHost(payload); - if (!DECLARED_HOSTS.has(host)) { - maybeRecordUnrecognisedPayload(payload, event); - return; - } - - // Read behind the host's own declaration, never one host's spelling promoted to a rule. - const sessionId = record.readSessionId(host, payload); - // JSON.stringify silently drops an undefined vendor_id, leaving a line missing the key - // every later join depends on. - if (typeof sessionId !== "string" || sessionId === "") return; - - const resolvedEvent = resolveEventName(event, payload); - if (resolvedEvent === "session-start") { - record.handleSessionStart(payload, host, sessionId); - } else if (resolvedEvent === "turn-end") { - // Before the turn_end line, so the run file's mtime still marks where this turn began - // - the observed pass reads that mark to know what changed since. - fileWrites.handleTaskFilesObserved(payload, host, sessionId); - record.handleTurnEnd(payload, host, sessionId); - } else if (resolvedEvent === "tool-used") { - // One event, three readings of it, sharing nothing else: each returns early on the - // shapes the others are looking for. - fileWrites.handleFileWritten(payload, host, sessionId); - stepStarts.handleStepStart(payload, host, sessionId); - stepEnds.handleStepEnd(payload, host, sessionId); - taskDeclared.handleTaskDeclared(payload, host, sessionId); - } -} - -function main() { - try { - const raw = readStdin(); - const payload = raw ? JSON.parse(raw) : null; - processPayload(payload, process.argv[2]); - } catch { - // Exit 0 no matter what: a measurement layer that breaks a session is worse than one - // that misses a session. - } -} - -if (require.main === module) { - main(); -} - -module.exports = { - detectHost, - parseOwnerRepoFromRemote: repo.parseOwnerRepoFromRemote, - sanitizeProjectId: repo.sanitizeProjectId, - runsDir: repo.runsDir, - telemetryEnabled: repo.telemetryEnabled, - generateUlid: record.generateUlid, - findRunFileByVendorId: record.findRunFileByVendorId, - looksLikeTaskPath: fileWrites.looksLikeTaskPath, - handleStepStart: stepStarts.handleStepStart, - processPayload, - resolveEventName, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/file-writes.cjs b/plugins/aidd-telemetry/hooks/lib/file-writes.cjs deleted file mode 100644 index 84f2f48eb..000000000 --- a/plugins/aidd-telemetry/hooks/lib/file-writes.cjs +++ /dev/null @@ -1,199 +0,0 @@ -// Which file writes are worth a line, and the path each accepted one appends. A written -// path is recorded, never a derived task_id: that derivation belongs to the reader. The -// task-folder gate below is a volume decision, not a task-identity one. - -const fs = require("node:fs"); - -const { normalizeSeparators } = require("./host.cjs"); -const { resolveRunsDir } = require("./repo.cjs"); -const { readCwd, toolFor, TOOLS_BY_HOST } = require("./tools/index.cjs"); -const path = require("node:path"); -const { findRunFileByVendorId, appendLine, buildFileWrittenLine, nowIso } = require("./record.cjs"); - -// Unanchored pre-filter, tested before any git shellout. A task is a folder of files or a -// single .md file, and both shapes exist side by side. -const TASK_SEGMENT_PATTERN = /aidd_docs\/tasks\/\d{4}_\d{2}\/[^/]+(\/|\.md$)/u; - -function looksLikeTaskPath(rawPath) { - return typeof rawPath === "string" && TASK_SEGMENT_PATTERN.test(normalizeSeparators(rawPath)); -} - -const TASK_PATH_ANCHOR_PATTERN = /^aidd_docs\/tasks\/\d{4}_\d{2}\/[^/]+(?:\/|\.md$)/u; - -// Anchored at repoRoot with a "/" boundary, not a bare string prefix, which would let -// repoRoot "/foo/bar" match a sibling "/foo/barbaz/...". -function taskFolderRelativePath(repoRoot, rawPath) { - if (typeof repoRoot !== "string" || !repoRoot || typeof rawPath !== "string" || !rawPath) return null; - const normalizedPath = normalizeSeparators(rawPath); - let root = normalizeSeparators(repoRoot); - if (!root.endsWith("/")) root += "/"; - if (!normalizedPath.startsWith(root)) return null; - const relative = normalizedPath.slice(root.length); - return TASK_PATH_ANCHOR_PATTERN.test(relative) ? relative : null; -} - -// The written-path field differs per tool, and Codex has no path field at all - it is inside -// an apply_patch command string. Each host's own tools file states its extractor, or null; -// gathered here for a caller that wants every host at once. -const WRITTEN_PATH_EXTRACTOR_BY_HOST = Object.freeze( - Object.fromEntries( - Object.entries(TOOLS_BY_HOST) - .filter(([, tool]) => tool.writtenPath) - .map(([host, tool]) => [host, tool.writtenPath]) - ) -); - -const TASKS_DIR = "aidd_docs/tasks"; -// A task folder holds documents, and a scan that walked node_modules would cost more than -// the git shellout this hook already pays on every event. Reaching this cap measures ~13.5ms -// p95, well inside the 200ms p95 the turn-end handler is held to. -const MAX_SCAN_ENTRIES = 2000; - -/** - * Every file under the task tree modified since `sinceMs`, repository-relative and - * "/"-separated. This is what makes a task attributable on a tool that never says what it - * wrote: the disk is the one thing every host shares. Scoped to the task tree, so a build - * touching a thousand files is never walked. - * - * `truncated` covers both ways the budget can run out - a listing cut short mid-read, or a - * directory queued and never opened. Checking `pending` alone misses the first, which is - * exactly the silent truncation this exists to catch. - */ -function taskFilesModifiedSince(repoRoot, sinceMs) { - const root = path.join(repoRoot, ...TASKS_DIR.split("/")); - const found = []; - const pending = [root]; - let seen = 0; - let cutShort = false; - while (pending.length > 0 && seen < MAX_SCAN_ENTRIES) { - const dir = pending.pop(); - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (seen >= MAX_SCAN_ENTRIES) { - cutShort = true; - break; - } - seen++; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - pending.push(full); - } else if (entry.isFile() && modifiedSince(full, sinceMs)) { - found.push(normalizeSeparators(path.relative(repoRoot, full))); - } - } - } - return { found, truncated: cutShort || pending.length > 0, scanned: seen }; -} - -function modifiedSince(filePath, sinceMs) { - try { - return fs.statSync(filePath).mtimeMs > sinceMs; - } catch { - return false; - } -} - -// Cheapest-first: this fires on every tool call, so a tool that wrote nothing must be -// rejected before anything is spawned or walked. -function handleFileWritten(payload, host, sessionId) { - const stated = statedRawPath(payload, host); - if (!stated) return; - - const target = resolveRunsDir(readCwd(host, payload)); - if (!target) return; - - const relativePath = taskFolderRelativePath(target.repoRoot, realPathOf(stated)); - if (!relativePath) return; - - // The session id arrives already read behind the host's own declaration. Reading - // payload.session_id here would be one host's spelling promoted to a rule, and on Codex it - // names the parent of a resumed session, so the lookup would find another session's file. - const filePath = findRunFileByVendorId(target.dir, sessionId); - if (filePath) appendFileWritten(filePath, relativePath, "tool-stated"); -} - -/** - * Everything in the task tree that changed during this turn, whoever wrote it. At turn end, - * not at every tool call: a turn ends once per prompt while tools fire dozens of times, and - * this is what catches a write no payload names. - * - * The moment recorded is the end of the turn rather than the write itself, because nothing - * here observed when the file changed, only that it had. - */ -function handleTaskFilesObserved(payload, host, sessionId) { - const target = resolveRunsDir(readCwd(host, payload)); - if (!target) return; - - const filePath = findRunFileByVendorId(target.dir, sessionId); - if (!filePath) return; - - // The run file's own mtime is the moment this session last wrote a line, so no state has - // to be kept: appending moves the mark forward on its own. - const since = lastWriteMs(filePath); - const { found, truncated, scanned } = taskFilesModifiedSince(target.repoRoot, since); - const alreadyStated = new Set(); - for (const observed of found) { - if (alreadyStated.has(observed)) continue; - alreadyStated.add(observed); - appendFileWritten(filePath, observed, "observed"); - } - // Silent truncation would read as complete coverage: a reader must be able to tell - // "nothing else changed" from "the walk gave up before it could tell". - if (truncated) { - appendLine(filePath, buildScanTruncatedLine({ at: nowIso(), cap: MAX_SCAN_ENTRIES, scanned })); - } -} - -function appendFileWritten(filePath, relativePath, source) { - appendLine(filePath, buildFileWrittenLine({ at: nowIso(), path: relativePath, source })); -} - -// Not a record.cjs builder: a reader ignores any type it does not name, so this one is inert -// to every existing reader rather than breaking one. -function buildScanTruncatedLine({ at, cap, scanned }) { - return { type: "scan_truncated", at, cap, scanned }; -} - -function lastWriteMs(filePath) { - try { - return fs.statSync(filePath).mtimeMs; - } catch { - return Date.now(); - } -} - -// git resolves symlinks in --show-toplevel and the tool's own path may not have. `.native` -// rather than the plain JS realpath: on Windows the JS one leaves an 8.3 short-name alias -// untouched, so it never matches git's already-canonical answer. Falls back to the raw path, -// so a file deleted between write and hook does not silently drop a real observation. -function realPathOf(rawPath) { - try { - return fs.realpathSync.native(rawPath); - } catch { - return rawPath; - } -} - -// The path the host handed us, when it hands one and it looks like a task path at all. -function statedRawPath(payload, host) { - const tool = toolFor(host); - const extractWrittenPath = tool && tool.writtenPath; - if (!extractWrittenPath) return null; - const rawPath = extractWrittenPath(payload); - return looksLikeTaskPath(rawPath) ? rawPath : null; -} - -module.exports = { - looksLikeTaskPath, - taskFolderRelativePath, - taskFilesModifiedSince, - MAX_SCAN_ENTRIES, - WRITTEN_PATH_EXTRACTOR_BY_HOST, - handleFileWritten, - handleTaskFilesObserved, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/host.cjs b/plugins/aidd-telemetry/hooks/lib/host.cjs deleted file mode 100644 index 9081d975b..000000000 --- a/plugins/aidd-telemetry/hooks/lib/host.cjs +++ /dev/null @@ -1,68 +0,0 @@ -// Claude Code and Codex hand a SessionStart hook the same five keys, so the host is read -// from transcript_path's shape. Not from the environment either: a Codex session launched -// inside a Claude Code session inherits that session's own variables. -const CODEX_TRANSCRIPT_PATTERN = /\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-/u; -const CLAUDE_CODE_TRANSCRIPT_PATTERN = /\/projects\/.*\.jsonl$/u; - -// Windows delivers transcript_path with "\" throughout; both patterns above assume "/". -function normalizeSeparators(value) { - return value.replace(/\\/gu, "/"); -} - -// Which field carries a path differs by host and by tool, so every string is walked. Neutral -// about whose payload it is walking. -function* stringsWithin(value) { - if (typeof value === "string") { - yield value; - return; - } - if (!value || typeof value !== "object") return; - for (const nested of Object.values(value)) yield* stringsWithin(nested); -} - -// A further host becomes one more entry here, never a branch in the dispatcher: detectHost -// stays the only place deciding which host a payload came from. -const DECLARED_HOSTS = new Set(["claude-code", "codex", "copilot", "cursor", "opencode"]); - -function detectHost(payload) { - if (!payload || typeof payload !== "object") return null; - - if (Object.prototype.hasOwnProperty.call(payload, "cursor_version")) { - return "cursor"; - } - - if ( - Object.prototype.hasOwnProperty.call(payload, "sessionId") && - !Object.prototype.hasOwnProperty.call(payload, "hook_event_name") - ) { - return "copilot"; - } - - if (typeof payload.transcript_path === "string") { - const transcriptPath = normalizeSeparators(payload.transcript_path); - // Codex checked first: both hosts' transcript_path end in .jsonl, and - // only the /sessions/ vs /projects/ segment tells them apart. - if (CODEX_TRANSCRIPT_PATTERN.test(transcriptPath)) return "codex"; - if (CLAUDE_CODE_TRANSCRIPT_PATTERN.test(transcriptPath)) return "claude-code"; - } - - // Copilot's other payload shape, which reuses Claude Code's own event spelling verbatim. - // Told apart by `timestamp`, a field neither Claude Code nor Codex carries, and checked - // after their transcript_path patterns so a host carrying one is claimed by its own shape. - if ( - Object.prototype.hasOwnProperty.call(payload, "timestamp") && - Object.prototype.hasOwnProperty.call(payload, "hook_event_name") && - Object.prototype.hasOwnProperty.call(payload, "session_id") - ) { - return "copilot"; - } - - // OpenCode alone names itself, checked last: every shape above was reverse-engineered from - // a payload nobody here controls, so a self-declared "tool" field only wins once none of - // them matched. OpenCode has no hook payload at all - opencode-plugin.js builds this one. - if (payload.tool === "opencode") return "opencode"; - - return null; -} - -module.exports = { detectHost, normalizeSeparators, stringsWithin, DECLARED_HOSTS }; diff --git a/plugins/aidd-telemetry/hooks/lib/plugin-version.cjs b/plugins/aidd-telemetry/hooks/lib/plugin-version.cjs deleted file mode 100644 index 3f849b315..000000000 --- a/plugins/aidd-telemetry/hooks/lib/plugin-version.cjs +++ /dev/null @@ -1,96 +0,0 @@ -// The plugin's own version, so a person comparing figures across an upgrade can tell which -// build of *this plugin* wrote a journal line. Never the framework's version and never the -// CLI's. -// -// Two routes, because there are two ways this plugin reaches a machine and neither can see -// the other's: a tool's own install lays down the plugin tree whole, so `plugin.json` sits -// two directories up under a per-target directory name; `aidd setup` copies the hooks alone -// with no manifest at any offset, and records the version in `.aidd/manifest.json` instead. -// -// Neither answering is `null` — an unknown version, never a default and never a guess: a hook -// firing on every tool call must not fail because a version is unavailable. - -const fs = require("node:fs"); -const path = require("node:path"); - -// Mirrors the manifest directory each tool's own profile declares. Not a shared import: this -// plugin is copied verbatim into user projects and can require nothing from `cli/`. -// `aidd-telemetry-plugin-version.test.js` pins this list to what those profiles declare. -const MANIFEST_DIRS = Object.freeze([ - ".claude-plugin", - ".cursor-plugin", - ".codex-plugin", - ".plugin", -]); - -// A constant rather than a derivation: after a flat install no fixed number of parent hops -// names the plugin on every route. Pinned by a test against the real manifest. -const PLUGIN_NAME = "aidd-telemetry"; - -// The journal's own host names on the left, the tool ids `.aidd/manifest.json` keys on the -// right. Only Claude Code spells them differently; the rest are listed anyway, so a further -// host is a line here rather than a silent `undefined`. -const AIDD_TOOL_ID_BY_HOST = Object.freeze({ - "claude-code": "claude", - codex: "codex", - copilot: "copilot", - cursor: "cursor", - opencode: "opencode", -}); - -// Never throws: a missing manifest, an unreadable one, invalid JSON and an absent `version` -// are all the same fact from a hook's point of view. -function readManifestVersion(manifestPath) { - try { - const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - return typeof parsed.version === "string" && parsed.version !== "" ? parsed.version : null; - } catch { - return null; - } -} - -// hooks/lib -> hooks -> aidd-telemetry -> /plugin.json -function versionBesideTheHooks() { - for (const dir of MANIFEST_DIRS) { - const version = readManifestVersion(path.join(__dirname, "..", "..", dir, "plugin.json")); - if (version !== null) return version; - } - return null; -} - -// Read for this host's own tool, never for whichever tool lists this plugin first: an update -// touches one tool's copy alone, so two entries can legitimately disagree. -function versionFromAiddManifest(repoRoot, host) { - const toolId = AIDD_TOOL_ID_BY_HOST[host]; - if (typeof repoRoot !== "string" || !repoRoot || toolId === undefined) return null; - try { - const manifest = JSON.parse( - fs.readFileSync(path.join(repoRoot, ".aidd", "manifest.json"), "utf8") - ); - const plugins = manifest?.tools?.[toolId]?.plugins; - if (!Array.isArray(plugins)) return null; - const entry = plugins.find((plugin) => plugin && plugin.name === PLUGIN_NAME); - const version = entry && entry.version; - return typeof version === "string" && version !== "" ? version : null; - } catch { - return null; - } -} - -/** - * This plugin's version, or `null` when neither route can name one. Not memoised: the answer - * depends on where the question is asked from, and the hook is a new process each time. - */ -function pluginVersion(repoRoot, host) { - return versionBesideTheHooks() ?? versionFromAiddManifest(repoRoot, host); -} - -module.exports = { - pluginVersion, - readManifestVersion, - versionBesideTheHooks, - versionFromAiddManifest, - MANIFEST_DIRS, - PLUGIN_NAME, - AIDD_TOOL_ID_BY_HOST, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/record.cjs b/plugins/aidd-telemetry/hooks/lib/record.cjs deleted file mode 100644 index f6ad8726c..000000000 --- a/plugins/aidd-telemetry/hooks/lib/record.cjs +++ /dev/null @@ -1,297 +0,0 @@ -// The run log itself: minting a run_id, naming and finding its file, and appending -// session_start / turn_end lines. Every write is one line; nothing here reads a run file -// back in order to write it again. - -const fs = require("node:fs"); -const path = require("node:path"); -const crypto = require("node:crypto"); - -const { - sanitizePathSegment, - resolveRunsDir, - resolveWriteTarget, - tightenOwnedDir, - tightenOwnedFile, - PRIVATE_DIR_MODE, -} = require("./repo.cjs"); -const { repairCommitTrailerHook } = require("./trailer-repair.cjs"); -const { TOOLS_BY_HOST, readCwd, readSessionId } = require("./tools/index.cjs"); -const { pluginVersion } = require("./plugin-version.cjs"); - -// Hand-rolled ULID - 48-bit millisecond timestamp plus 80 bits of randomness, both -// Crockford base32 - since this plugin ships with no dependencies. - -const CROCKFORD_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // 32 symbols, 5 bits each; no I/L/O/U. - -function encodeTime(time, length) { - let chars = ""; - let remaining = time; - for (let i = 0; i < length; i++) { - const mod = remaining % 32; - chars = CROCKFORD_ALPHABET[mod] + chars; - remaining = (remaining - mod) / 32; - } - return chars; -} - -function encodeRandom(length) { - // One byte per character: unbiased because 256 is a multiple of 32. - const bytes = crypto.randomBytes(length); - let chars = ""; - for (let i = 0; i < length; i++) { - chars += CROCKFORD_ALPHABET[bytes[i] % 32]; - } - return chars; -} - -function generateUlid(now = Date.now()) { - return encodeTime(now, 10) + encodeRandom(16); -} - -const ULID_LENGTH = 10 + 16; // encodeTime(10) + encodeRandom(16), kept in step with generateUlid. - -function nowIso() { - return new Date().toISOString().replace(/\.\d{3}Z$/u, "Z"); -} - -// `__.jsonl`. A JSON object is a closed block that can only be -// rewritten whole; a line-per-object file can be appended to. -const RUN_FILE_EXTENSION = ".jsonl"; - -function runFileName(runId, vendorId) { - return `${runId}__${sanitizePathSegment(String(vendorId))}${RUN_FILE_EXTENSION}`; -} - -// Splits on the fixed ULID_LENGTH rather than on "__", which a sanitised vendor_id may -// itself contain. -function parseRunFileName(entry) { - if (!entry.endsWith(RUN_FILE_EXTENSION)) return null; - const minLength = ULID_LENGTH + "__".length + RUN_FILE_EXTENSION.length; - if (entry.length <= minLength) return null; - if (entry.slice(ULID_LENGTH, ULID_LENGTH + 2) !== "__") return null; - return { - runId: entry.slice(0, ULID_LENGTH), - vendorSegment: entry.slice(ULID_LENGTH + 2, -RUN_FILE_EXTENSION.length), - }; -} - -// Matches on the directory listing alone - no file read, no JSON parse - since turn-end -// and file-written both call this on every event. -function findRunFileByVendorId(dir, vendorId) { - let entries; - try { - entries = fs.readdirSync(dir); - } catch { - return null; - } - - const wanted = sanitizePathSegment(String(vendorId)); - for (const entry of entries) { - const parsed = parseRunFileName(entry); - if (parsed && parsed.vendorSegment === wanted) return path.join(dir, entry); - } - return null; -} - -// Bumped from 1 when the mutable record became this append-only line log. Written once, -// on session_start, so a reader can tell a file's shape without scanning it. -const SCHEMA_VERSION = 2; - -// Which export-side attribute vendor_id joins against, per host: each host's own tools file -// states it, measured or explicitly null, and this gathers them into one shape. -const VENDOR_FIELD_BY_HOST = Object.freeze( - Object.fromEntries(Object.entries(TOOLS_BY_HOST).map(([host, tool]) => [host, tool.vendorField])) -); - -// Re-exported below under exactly these names: the CLI's test helpers reach them that way. -const { codexSessionIdFromTranscriptPath } = require("./tools/codex.cjs"); - -const PRIVATE_FILE_MODE = 0o600; - -// `mode` takes effect only on the append that creates the file, which the session_start -// line always is. -function appendLine(filePath, line) { - fs.appendFileSync(filePath, `${JSON.stringify(line)}\n`, { mode: PRIVATE_FILE_MODE }); -} - -// worktree_id, worktree_repo_id and plugin_version are omitted entirely, never null and -// never "", when nothing can name one - see `worktreeFields` in repo.cjs for why absence is -// the only honest answer. They are appended after the fields already on this line, so a -// reader keyed on order sees no existing key move, and an optional field a reader may not -// know about needs no schema_version bump. -// -// plugin_version is stamped once, on the line that names the session: it is a fact about -// which build of this plugin observed the session, never the framework's or the CLI's. -function buildSessionStartLine({ - at, - runId, - projectId, - projectRemote, - host, - vendorId, - worktreeId, - worktreeRepoId, - repoRoot, -}) { - const version = pluginVersion(repoRoot, host); - return { - type: "session_start", - at, - schema_version: SCHEMA_VERSION, - run_id: runId, - project_id: projectId, - project_remote: projectRemote, - tool: host, - vendor_id: vendorId, - vendor_field: VENDOR_FIELD_BY_HOST[host], - ...(worktreeId === undefined ? {} : { worktree_id: worktreeId }), - ...(worktreeRepoId === undefined ? {} : { worktree_repo_id: worktreeRepoId }), - ...(version === null ? {} : { plugin_version: version }), - }; -} - -// prompt_id is omitted, never written as null, when the payload carries none. -function buildTurnEndLine({ at, promptId }) { - const line = { type: "turn_end", at }; - if (typeof promptId === "string" && promptId !== "") line.prompt_id = promptId; - return line; -} - -// path is repository-relative and "/"-separated on every platform, never a task_id: that -// derivation belongs to the reader. `source` says how the path came to be known - -// "tool-stated" is exact, "observed" is a file that changed inside a task folder while the -// session ran, which can in principle catch a write something else on the machine made. -function buildFileWrittenLine({ at, path: writtenPath, source }) { - return { type: "file_written", at, path: writtenPath, source }; -} - -// A start and nothing else: no tool exposes when a skill's work finishes, so an end, a -// duration or a parent would be a conclusion stored as a fact. The skill name is sanitised -// as a value, never as a path segment. -function buildStepStartLine({ at, skill, turnId }) { - const line = { type: "step_start", at, skill: sanitizeSkillName(skill) }; - if (typeof turnId === "string" && turnId !== "") line.turn_id = turnId; - return line; -} - -// Told rather than inferred - see step-ends.cjs for why no hook can observe it. Carries the -// skill, never only the moment: a bare end closes whatever step is open, which is the wrong -// one as soon as a skill invokes another. -function buildStepEndLine({ at, skill }) { - return { type: "step_end", at, skill: sanitizeSkillName(skill) }; -} - -// A tool call named a task path, so this session is on that task from here. No end on this -// line, for the reason step_start carries none: closing is the reader's derivation, from -// whichever turn_end or later task_declared comes next. -function buildTaskDeclaredLine({ at, path: declaredPath }) { - return { type: "task_declared", at, path: declaredPath }; -} - -// Separators and traversal collapse to "-", so a hostile name cannot read as a path or -// escape its own field. Emptied entirely, it reads "-" rather than vanishing. -function sanitizeSkillName(skill) { - const cleaned = String(skill).replace(/[^\w.:-]/gu, "-"); - return cleaned === "" || cleaned === "." || cleaned === ".." ? "-" : cleaned; -} - -// sessionId and the working directory both arrive behind the host's own declaration: Cursor -// names its directory workspace_roots, never cwd. -function handleSessionStart(payload, host, sessionId) { - const target = resolveWriteTarget(readCwd(host, payload)); - if (!target) return; - const { projectId, projectRemote, dir, repoRoot, worktreeId, worktreeRepoId } = target; - - // Before the run file and outside the guard below: the trailer's call site can go missing - // where another tool regenerates `prepare-commit-msg`, and the session that already has a - // run file is exactly the one whose next SessionStart should put it back. Here because - // this is the only place already holding an enabled repository's hooks directory. - repairCommitTrailerHook(target.hooksDir, target.gitDir, repoRoot); - - // SessionStart is not documented to fire once per session_id, so this guard prevents a - // second file for one vendor_id. - if (findRunFileByVendorId(dir, sessionId)) return; - - const runId = generateUlid(); - const line = buildSessionStartLine({ - at: nowIso(), - runId, - projectId, - projectRemote, - host, - vendorId: sessionId, - worktreeId, - worktreeRepoId, - repoRoot, - }); - - fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE }); - const filePath = path.join(dir, runFileName(runId, sessionId)); - appendLine(filePath, line); - tightenOwnedDir(dir); - tightenOwnedFile(filePath); -} - -// Driven by Stop, not a session-end event: Codex grants a session-end handler one second -// at most and does not fire it for subagents, so the last turn-end is the only reliable end. -function handleTurnEnd(payload, host, sessionId) { - const target = resolveRunsDir(readCwd(host, payload)); - if (!target) return; - const { dir } = target; - - const filePath = findRunFileByVendorId(dir, sessionId); - if (!filePath) return; - - appendLine(filePath, buildTurnEndLine({ at: nowIso(), promptId: payload.prompt_id })); -} - -// An unrecognised payload has no readable session and so no run file to append to: it lands -// in one file shared by the repository, named so it can never collide with a run file. -const UNRECOGNISED_FILE_NAME = "_unrecognised.jsonl"; - -// Overwritten, not appended: the file stays at exactly one line however many events arrive, -// and `at` is always the most recent, since a marker frozen on the first would read as stale -// forever. -function handleUnrecognisedPayload(payload) { - // An unrecognised payload's shape is by definition unknown - it may spell its working - // directory differently or carry none, as Cursor already does - so payload.cwd is used - // only when usable, and process.cwd() falls back on where this hook is running. - const cwd = - payload && typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd(); - const target = resolveRunsDir(cwd); - if (!target) return; - - fs.mkdirSync(target.dir, { recursive: true, mode: PRIVATE_DIR_MODE }); - const filePath = path.join(target.dir, UNRECOGNISED_FILE_NAME); - const line = `${JSON.stringify({ type: "unrecognised_payload", at: nowIso() })}\n`; - fs.writeFileSync(filePath, line, { mode: PRIVATE_FILE_MODE }); - tightenOwnedDir(target.dir); - tightenOwnedFile(filePath); -} - -module.exports = { - generateUlid, - ULID_LENGTH, - nowIso, - RUN_FILE_EXTENSION, - runFileName, - parseRunFileName, - findRunFileByVendorId, - SCHEMA_VERSION, - VENDOR_FIELD_BY_HOST, - codexSessionIdFromTranscriptPath, - readSessionId, - appendLine, - buildSessionStartLine, - buildTurnEndLine, - buildFileWrittenLine, - buildStepStartLine, - buildStepEndLine, - buildTaskDeclaredLine, - sanitizeSkillName, - PRIVATE_FILE_MODE, - handleSessionStart, - handleTurnEnd, - UNRECOGNISED_FILE_NAME, - handleUnrecognisedPayload, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/repo.cjs b/plugins/aidd-telemetry/hooks/lib/repo.cjs deleted file mode 100644 index 1951fcf48..000000000 --- a/plugins/aidd-telemetry/hooks/lib/repo.cjs +++ /dev/null @@ -1,302 +0,0 @@ -// The repository root, the telemetry switch, and where a session's record lives. The -// switch is `.aidd/config.json`'s `telemetry.enabled`, read fresh at every call. - -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); - -// git exports GIT_DIR and friends into every process it spawns, so a session started -// from inside a git hook would resolve someone else's repository instead of its own. -function gitEnv() { - const env = {}; - for (const key of Object.keys(process.env)) { - if (!key.startsWith("GIT_")) env[key] = process.env[key]; - } - return env; -} - -function getRepoRoot(cwd) { - if (typeof cwd !== "string" || !cwd) return null; - try { - const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { - cwd, - encoding: "utf8", - env: gitEnv(), - }); - if (result.status !== 0) return null; - const root = result.stdout.trim(); - return root || null; - } catch { - return null; - } -} - -// Where a linked worktree's git directory always sits: `/worktrees/`. -const WORKTREES_SEGMENT = "worktrees"; -const GIT_DIR_NAME = ".git"; - -// Taken from git, never from an agent runner's own variable, which names that runner's -// concept rather than the repository's. -// -// A plain checkout gets NEITHER field: absent, never null and never "" - an empty string -// would gather every plain checkout into one group as though they were the same worktree. -// -// The layout is the test, never a comparison of the two directories: two spellings of one -// path would compare unequal and write a worktree field on a plain checkout. `path.resolve` -// comes first because `git rev-parse` prints these relative for a plain checkout. -function worktreeFields(cwd, commonDir, gitDir) { - if (!commonDir || !gitDir) return {}; - const resolvedGitDir = path.resolve(cwd, gitDir); - // A plain checkout's git directory is the literal `.git`, so a repository living in a - // directory named `worktrees` would otherwise pass the layout test below. A linked - // worktree's git directory is named for the worktree and is never `.git`. - if (path.basename(resolvedGitDir) === GIT_DIR_NAME) return {}; - if (path.basename(path.dirname(resolvedGitDir)) !== WORKTREES_SEGMENT) return {}; - const repoName = repositoryNameFromCommonDir(path.resolve(cwd, commonDir)); - return { - worktreeId: sanitizePathSegment(path.basename(resolvedGitDir)), - ...(repoName === null ? {} : { worktreeRepoId: repoName }), - }; -} - -// `/.git` and a bare `.git` both answer ``. Recorded beside the worktree -// rather than left to `project_id`, which falls back to the worktree's own directory name -// when a clone has no remote - so two worktrees of one clone would look unrelated. -function repositoryNameFromCommonDir(commonDir) { - const base = path.basename(commonDir); - const name = - base === GIT_DIR_NAME ? path.basename(path.dirname(commonDir)) : base.replace(/\.git$/u, ""); - return name === "" || name === "." || name === ".." ? null : sanitizePathSegment(name); -} - -// One `git rev-parse` in the ordinary case, and never more than two. `--git-path hooks` -// honours `core.hooksPath`, which joining `.git/hooks` by hand does not, but `rev-parse` -// fails atomically: a git that does not understand one option answers non-zero for all of -// them, and a null here makes the journal write nothing at all. So the four-option form is -// asked first, and an older git pays a second call and goes without the hooks directory. -function getRepoLocation(cwd) { - if (typeof cwd !== "string" || !cwd) return null; - try { - const withHooks = revParse(cwd, [...LOCATION_OPTIONS, "--git-path", "hooks"]); - const parts = withHooks ?? revParse(cwd, LOCATION_OPTIONS); - if (!parts) return null; - const [root, commonDir, gitDir, hooksDir] = parts; - if (!root) return null; - return { - repoRoot: root, - // Carried so the trailer repair can tell a hooks directory inside the git directory - // from one inside the working tree, which is version-controlled content. - ...(commonDir ? { gitDir: path.resolve(cwd, commonDir) } : {}), - ...worktreeFields(cwd, commonDir, gitDir), - // Against `cwd`, never against the repository root: `git rev-parse` prints this - // relative to the directory it ran in, so resolving against the root sends a session - // started in `sub/deep` two levels above the checkout, which is where repair writes. - ...(hooksDir ? { hooksDir: path.resolve(cwd, hooksDir) } : {}), - }; - } catch { - return null; - } -} - -const LOCATION_OPTIONS = ["--show-toplevel", "--git-common-dir", "--git-dir"]; - -/** `null` when `rev-parse` refused. A git too old for one option refuses all of them, which - * is why the caller falls back to a shorter form rather than a shorter reading of this. */ -function revParse(cwd, options) { - const result = spawnSync("git", ["rev-parse", ...options], { - cwd, - encoding: "utf8", - env: gitEnv(), - }); - if (result.status !== 0) return null; - return String(result.stdout) - .split("\n") - .map((part) => part.trim()); -} - -// hooks/ is copied verbatim with no install step, so JSON.parse is the only parser here. -function readTelemetryConfig(repoRoot) { - try { - return JSON.parse(fs.readFileSync(path.join(repoRoot, ".aidd", "config.json"), "utf8")); - } catch { - return null; - } -} - -// The only refusal at a person's own scope, and a variable rather than a second config file: -// it is refusable per shell, per session and per machine, and needs nothing created. -// -// Only the literal "0" is a refusal - unset or empty is not a choice this can express, and it -// never turns measurement on by itself. The CLI's own `personRefusesTelemetry` mirrors this -// exactly, so the two can never disagree about whether a person has refused. -const TELEMETRY_REFUSAL_VARIABLE = "AIDD_TELEMETRY"; - -function personRefusesTelemetry() { - return process.env[TELEMETRY_REFUSAL_VARIABLE] === "0"; -} - -// Strict `=== true`, not truthy: a half-written config must read as off. The person's refusal -// is read first and wins unconditionally - no project out-ranks the person running it. -function telemetryEnabled(repoRoot) { - if (personRefusesTelemetry()) return false; - const config = readTelemetryConfig(repoRoot); - return Boolean(config && config.telemetry && config.telemetry.enabled === true); -} - -function getRemoteUrl(repoRoot) { - try { - const result = spawnSync("git", ["remote", "get-url", "origin"], { - cwd: repoRoot, - encoding: "utf8", - env: gitEnv(), - }); - if (result.status !== 0) return null; - const url = result.stdout.trim(); - return url || null; - } catch { - return null; - } -} - -// git@github.com:owner/repo.git -> owner/repo -// https://github.com/owner/repo.git -> owner/repo -// A GitLab subgroup path collapses to its last two segments. -function parseOwnerRepoFromRemote(remoteUrl) { - if (typeof remoteUrl !== "string") return null; - const trimmed = remoteUrl.trim().replace(/\.git$/u, ""); - if (!trimmed) return null; - - const sshMatch = trimmed.match(/^[^@\s/]+@[^:\s/]+:(.+)$/u); - const urlMatch = trimmed.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/(?:[^/@\s]+@)?[^/\s]+\/(.+)$/u); - const captured = sshMatch ? sshMatch[1] : urlMatch ? urlMatch[1] : null; - if (!captured) return null; - - const segments = captured.split("/").filter(Boolean); - if (segments.length < 2) return null; - return segments.slice(-2).join("/"); -} - -// Never bare "." or ".." - either walks the tree instead of naming something in it. -function sanitizePathSegment(segment) { - const cleaned = String(segment).replace(/[^\w.-]/gu, "-"); - return cleaned === "" || cleaned === "." || cleaned === ".." ? "-" : cleaned; -} - -function sanitizeProjectId(projectId) { - return projectId.split("/").filter(Boolean).map(sanitizePathSegment).join("/"); -} - -// Split from deriveProjectId so a caller holding remoteUrl pays one git shellout, not two. -function projectIdFromRemote(repoRoot, remoteUrl) { - const ownerRepo = remoteUrl ? parseOwnerRepoFromRemote(remoteUrl) : null; - const raw = ownerRepo || path.basename(repoRoot); - return sanitizeProjectId(raw); -} - -// The CLI duplicates this algorithm in telemetry-project-id.ts and a test proves the two -// agree, so the signature is not this plugin's alone to change. -function deriveProjectId(repoRoot) { - return projectIdFromRemote(repoRoot, getRemoteUrl(repoRoot)); -} - -// `AIDD_RUNS_DIR` overrides outright. The directory existing is not a second gate. -function runsDir(repoRoot) { - return process.env.AIDD_RUNS_DIR || path.join(repoRoot, "aidd_docs", "runs"); -} - -// What this hook writes is who-worked-on-what-for-how-long, so it is not left world-readable. -// Windows accepts this mode without error and does nothing with it - everything lands at 0666 -// regardless - so privacy there comes from restrictToCurrentUser below instead. -const PRIVATE_DIR_MODE = 0o700; - -// `mkdirSync`'s `mode` applies only to a directory it creates, so a checked-out runs -// directory needs this chmod - and on Windows needs it reset on every write, since anything -// could have widened it since. Never on a user-named AIDD_RUNS_DIR: that is theirs to set. -function tightenOwnedDir(dir) { - if (process.env.AIDD_RUNS_DIR) return; - if (process.platform === "win32") return restrictToCurrentUser(dir, { inheritable: true }); - try { - fs.chmodSync(dir, PRIVATE_DIR_MODE); - } catch { - // Foreign owner, read-only mount: leave it as it is. - } -} - -// POSIX needs no second pass: `appendFileSync`'s own `mode` set 0600 as it created the file. -function tightenOwnedFile(filePath) { - if (process.env.AIDD_RUNS_DIR) return; - if (process.platform === "win32") restrictToCurrentUser(filePath); -} - -// The real mechanism on Windows: reset the target's NTFS ACL to inherit nothing and grant -// Full Control to the current user alone. `inheritable` adds `(OI)(CI)` so a directory's -// future children pick up the grant; a file has no children to inherit anything. -// -// Never `/T`: it walks into files this code does not own and can leave one with no usable -// ACE, so an ordinary `git add -A` then fails with "Permission denied". `/C` keeps icacls -// going past one bad entry, and its exit code is not trusted as proof of anything. -function restrictToCurrentUser(target, { inheritable = false } = {}) { - try { - const owner = process.env.USERDOMAIN - ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` - : process.env.USERNAME || os.userInfo().username; - if (!owner) return; - const grant = inheritable ? `${owner}:(OI)(CI)F` : `${owner}:F`; - const args = [target, "/inheritance:r", "/grant:r", grant, "/C", "/Q"]; - spawnSync("icacls", args, { encoding: "utf8" }); - } catch { - // icacls missing, no resolvable owner, or a domain-policy refusal: leave it as it is. - } -} - -// A decision, not an inherited default: a worktree keeps its own journal, at the worktree's -// own root and never at `--git-common-dir`'s shared repository. -// -// A bare clone plus worktrees has no main working tree to write into at all, and even where -// one exists, writing into it from another worktree dirties a checkout on a different branch -// whose `.gitignore` was never asked to carry the entry. Cross-worktree joining is served by -// `worktreeFields` naming the worktree on `session_start` instead. -function resolveRunsDir(cwd) { - const location = getRepoLocation(cwd); - if (!location || !telemetryEnabled(location.repoRoot)) return null; - return { ...location, dir: runsDir(location.repoRoot) }; -} - -// A token-authenticated clone leaves a live credential in the remote's userinfo, and the -// journal is meant to be read and shipped. Only a scheme-bearing URL has userinfo. -function remoteWithoutCredentials(remoteUrl) { - if (typeof remoteUrl !== "string") return null; - return remoteUrl.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/]*@/u, "$1"); -} - -// project_remote sits beside project_id so a changed remote can be re-derived instead of -// silently splitting a project in two. -function resolveWriteTarget(cwd) { - const target = resolveRunsDir(cwd); - if (!target) return null; - const remoteUrl = getRemoteUrl(target.repoRoot); - const projectId = projectIdFromRemote(target.repoRoot, remoteUrl); - return { ...target, projectId, projectRemote: remoteWithoutCredentials(remoteUrl) }; -} - -module.exports = { - getRepoRoot, - getRepoLocation, - readTelemetryConfig, - telemetryEnabled, - personRefusesTelemetry, - TELEMETRY_REFUSAL_VARIABLE, - getRemoteUrl, - remoteWithoutCredentials, - parseOwnerRepoFromRemote, - sanitizePathSegment, - sanitizeProjectId, - deriveProjectId, - runsDir, - PRIVATE_DIR_MODE, - tightenOwnedDir, - tightenOwnedFile, - resolveRunsDir, - resolveWriteTarget, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/step-ends.cjs b/plugins/aidd-telemetry/hooks/lib/step-ends.cjs deleted file mode 100644 index 20ba8ae35..000000000 --- a/plugins/aidd-telemetry/hooks/lib/step-ends.cjs +++ /dev/null @@ -1,78 +0,0 @@ -// When a skill's work finished, told rather than inferred: no host emits it. A `Skill` call's -// `tool_result` comes back a tenth of a second later, which is the dispatch and not the -// completion, so the only party that knows the work is over is the skill itself. -// -// Read out of the call's own free-form arguments, which asks nothing of the host, so every -// host forwarding tool arguments is covered. The hook stays the writer: a script a skill -// invoked directly would carry no session id and no cwd, and could find no run file. - -const fs = require("node:fs"); - -const { normalizeSeparators, stringsWithin } = require("./host.cjs"); -const { resolveRunsDir } = require("./repo.cjs"); -const { readCwd } = require("./tools/index.cjs"); -const { findRunFileByVendorId, appendLine, buildStepEndLine, nowIso } = require("./record.cjs"); - -// The marker, then the skill it closes: closing "whatever step is open" closes the wrong one -// the moment a skill invokes another. The name must begin with a letter, which refuses a path -// fragment, and the rest admits no slash, space or shell metacharacter, so a later reader -// never has to defend against one. -const STEP_END_PATTERN = /aidd:step-end[ \t]+([A-Za-z][\w.-]*(?::[\w.-]+)?)/u; - -// A call merely *mentioning* the marker closes a step it never ran - the same false positive -// `task-declared.cjs` accepts, bounded the same way: an end can only close an interval its own -// session opened for that exact skill, so any other mention writes a line the reader ignores. -function firstStepEndIn(value) { - for (const candidate of stringsWithin(value)) { - const match = STEP_END_PATTERN.exec(normalizeSeparators(candidate)); - if (match) return match[1]; - } - return null; -} - -// tool_input is every declared host's shape for a call's arguments; only Copilot's canonical -// builder spells them toolArgs, as a JSON string a plain scan reads just as well. -function declaredStepEnd(payload) { - return firstStepEndIn(payload.tool_input) ?? firstStepEndIn(payload.toolArgs); -} - -// The watermark `task-declared.cjs` protects, for the same reason: file-writes.cjs reads the -// run file's mtime as "the moment this session last wrote a line", and an end landing between -// a shell write and the turn end that observes it would push that mark past the write. -function mtimeOf(filePath) { - try { - return fs.statSync(filePath).mtime; - } catch { - return null; - } -} - -function restoreMtime(filePath, mtime) { - try { - fs.utimesSync(filePath, mtime, mtime); - } catch { - // A run file that vanished between the append and this restore costs the watermark, not - // the line - the same tolerance every other write in this directory takes. - } -} - -function handleStepEnd(payload, host, sessionId) { - const skill = declaredStepEnd(payload); - if (!skill) return; - - const target = resolveRunsDir(readCwd(host, payload)); - if (!target) return; - - const filePath = findRunFileByVendorId(target.dir, sessionId); - if (!filePath) return; - - const before = mtimeOf(filePath); - appendLine(filePath, buildStepEndLine({ at: nowIso(), skill })); - if (before) restoreMtime(filePath, before); -} - -module.exports = { - STEP_END_PATTERN, - declaredStepEnd, - handleStepEnd, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/step-starts.cjs b/plugins/aidd-telemetry/hooks/lib/step-starts.cjs deleted file mode 100644 index de9f151c0..000000000 --- a/plugins/aidd-telemetry/hooks/lib/step-starts.cjs +++ /dev/null @@ -1,45 +0,0 @@ -// Which tool calls open a step. How each host answers that now lives in its own file -// under hooks/lib/tools/ - this only dispatches to it. Only the start is recorded: no tool -// measured so far exposes when a skill's work finishes, so the interval is the reader's -// derivation from the lines that follow. - -const { resolveRunsDir } = require("./repo.cjs"); -const { readCwd, toolFor, TOOLS_BY_HOST } = require("./tools/index.cjs"); -const { findRunFileByVendorId, appendLine, buildStepStartLine, nowIso } = require("./record.cjs"); -const { SKILL_FILE_PATTERN } = require("./tools/skill-detection.cjs"); - -// Gathered from the per-host declarations, for a caller that wants every host at once. A host -// with no stepStart is simply absent. -const STEP_START_BY_HOST = Object.freeze( - Object.fromEntries( - Object.entries(TOOLS_BY_HOST) - .filter(([, tool]) => tool.stepStart) - .map(([host, tool]) => [host, tool.stepStart]) - ) -); - -// Its own guard chain, deliberately not `handleFileWritten`'s: that one returns early -// unless the path looks like a task folder, and a skill call has no task path. -function handleStepStart(payload, host, sessionId) { - const tool = toolFor(host); - const declaration = tool && tool.stepStart; - if (!declaration) return; - - const skill = declaration.skillName(payload); - if (!skill) return; - - const target = resolveRunsDir(readCwd(host, payload)); - if (!target) return; - - const filePath = findRunFileByVendorId(target.dir, sessionId); - if (!filePath) return; - - const turnId = declaration.turnIdField ? payload[declaration.turnIdField] : undefined; - appendLine(filePath, buildStepStartLine({ at: nowIso(), skill, turnId })); -} - -module.exports = { - SKILL_FILE_PATTERN, - STEP_START_BY_HOST, - handleStepStart, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/task-declared.cjs b/plugins/aidd-telemetry/hooks/lib/task-declared.cjs deleted file mode 100644 index 2c88d03be..000000000 --- a/plugins/aidd-telemetry/hooks/lib/task-declared.cjs +++ /dev/null @@ -1,83 +0,0 @@ -// Which ticket a session is on, told rather than inferred. Inferring it from a written path -// works only where the host hands one over in readable form; a declaration asks nothing of the -// host, since any call whose arguments name a file under a task folder is evidence the flow is -// on that task, and naming the file is what calling the tool already requires. - -const fs = require("node:fs"); - -const { normalizeSeparators, stringsWithin } = require("./host.cjs"); -const { resolveRunsDir } = require("./repo.cjs"); -const { readCwd, toolFor } = require("./tools/index.cjs"); -const { findRunFileByVendorId, appendLine, buildTaskDeclaredLine, nowIso } = require("./record.cjs"); - -// Unanchored and tolerant of sitting inside a larger string, closed by a quote or whitespace. -// Two shapes, matching file-writes.cjs's own TASK_SEGMENT_PATTERN exactly: a folder task -// continues past a "/" into its file, a single-file task ends the segment in ".md", and a bare -// segment with neither is not a task path in either place. -const TASK_PATH_PATTERN = - /aidd_docs\/tasks\/\d{4}_\d{2}\/[^/"'\s]+\/[^"'\s]*|aidd_docs\/tasks\/\d{4}_\d{2}\/[^/"'\s]+\.md/u; - -function firstTaskPathIn(value) { - for (const candidate of stringsWithin(value)) { - const match = TASK_PATH_PATTERN.exec(normalizeSeparators(candidate)); - if (match) return match[0]; - } - return null; -} - -// tool_input is every declared host's shape for a call's arguments; only Copilot's canonical -// builder spells them toolArgs, as a JSON string a plain scan reads just as well. -function declaredTaskPath(payload) { - return firstTaskPathIn(payload.tool_input) ?? firstTaskPathIn(payload.toolArgs); -} - -// A write this host's own extractor can name is handleFileWritten's claim: that reading is -// exact, where a declaration is an inference from arguments text, and both firing on one event -// would be two claims about one write. A Bash write, which no extractor reads on any host, is -// not excluded and reaches the declaration below on its arguments text. -function statedAsWrittenAlready(payload, host) { - const tool = toolFor(host); - const extractWrittenPath = tool && tool.writtenPath; - return typeof extractWrittenPath === "function" && typeof extractWrittenPath(payload) === "string"; -} - -// A declaration must never move the run file's mtime forward: the observed pass reads it as -// "the moment this session last wrote a line", and a mention landing between a shell write and -// the turn end that observes it would push the watermark past that write. -function mtimeOf(filePath) { - try { - return fs.statSync(filePath).mtime; - } catch { - return null; - } -} - -function restoreMtime(filePath, mtime) { - try { - fs.utimesSync(filePath, mtime, mtime); - } catch { - // Best effort: a failed restore costs the next observed pass a possible false negative - // for an unrelated write, never the declaration itself, which is already on disk. - } -} - -// Its own guard chain, not handleFileWritten's: this fires for any call on any host, because -// the evidence it reads is the arguments themselves rather than a named field. -function handleTaskDeclared(payload, host, sessionId) { - if (statedAsWrittenAlready(payload, host)) return; - - const path = declaredTaskPath(payload); - if (!path) return; - - const target = resolveRunsDir(readCwd(host, payload)); - if (!target) return; - - const filePath = findRunFileByVendorId(target.dir, sessionId); - if (!filePath) return; - - const before = mtimeOf(filePath); - appendLine(filePath, buildTaskDeclaredLine({ at: nowIso(), path })); - if (before) restoreMtime(filePath, before); -} - -module.exports = { TASK_PATH_PATTERN, declaredTaskPath, handleTaskDeclared }; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/claude-code.cjs b/plugins/aidd-telemetry/hooks/lib/tools/claude-code.cjs deleted file mode 100644 index 8040b54fd..000000000 --- a/plugins/aidd-telemetry/hooks/lib/tools/claude-code.cjs +++ /dev/null @@ -1,38 +0,0 @@ -// Everything the journal knows about Claude Code: session_id and cwd straight off its own -// payload, tool_input.file_path/notebook_path naming a write, tool_name "Skill" naming its -// own step with prompt_id as the turn it belongs to. - -const { skillNameFromArgument } = require("./skill-detection.cjs"); - -const WRITE_TOOL_PATH_FIELDS = Object.freeze({ - Write: "file_path", - Edit: "file_path", - NotebookEdit: "notebook_path", -}); - -function writtenPath(payload) { - const field = WRITE_TOOL_PATH_FIELDS[payload.tool_name]; - if (!field) return null; - const value = payload.tool_input && payload.tool_input[field]; - return typeof value === "string" && value ? value : null; -} - -module.exports = { - readSessionId: (payload) => payload.session_id, - readCwd: (payload) => payload.cwd, - // CLAUDE_TELEMETRY_IDENTITY_ATTRIBUTE, measured on its own export. - vendorField: "session.id", - stepStart: { - skillName: skillNameFromArgument({ - toolField: "tool_name", - toolName: "Skill", - argumentsField: "tool_input", - nameField: "skill", - }), - turnIdField: "prompt_id", - }, - // Claude Code alone: no other host was captured handing a path to a hook. A host without - // one is not blind to tasks - file-writes.cjs's observed pass covers it - but a stated path - // is exact where an observed one is inferred. - writtenPath, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/codex.cjs b/plugins/aidd-telemetry/hooks/lib/tools/codex.cjs deleted file mode 100644 index 5fb6b633e..000000000 --- a/plugins/aidd-telemetry/hooks/lib/tools/codex.cjs +++ /dev/null @@ -1,48 +0,0 @@ -// Everything the journal knows about Codex: session identity from the rollout it is -// actually writing rather than from its payload's own session_id, cwd straight off its -// payload, a SKILL.md read naming its step with turn_id as the turn it belongs to, and no -// written-path extractor - a write reaches the journal through an apply_patch command -// string, which no field here names. - -const { skillNameFromSkillFileRead } = require("./skill-detection.cjs"); - -// A Codex rollout is named `rollout--.jsonl`, and that trailing uuid is the -// identity both sides join on: the hook writes it as `vendor_id`, the reader resolves a -// session by it. The join is filename to filename, and holds by construction. It is never -// read as `session_meta.id`, which a rollout can carry differently from its own filename. -// -// The two parses live apart because hooks/ is copied verbatim and can import nothing from -// cli/ - the same reason sanitizePathSegment is duplicated - so a test pins them to each -// other and turns red if either moves. -const CODEX_ROLLOUT_PREFIX = "rollout-"; -const CODEX_ROLLOUT_EXTENSION = ".jsonl"; -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; - -function codexSessionIdFromTranscriptPath(transcriptPath) { - if (typeof transcriptPath !== "string" || transcriptPath === "") return undefined; - const base = transcriptPath.split(/[\\/]/u).pop() || ""; - if (!base.startsWith(CODEX_ROLLOUT_PREFIX) || !base.endsWith(CODEX_ROLLOUT_EXTENSION)) { - return undefined; - } - const stem = base.slice(0, -CODEX_ROLLOUT_EXTENSION.length); - const candidate = stem.slice(-36); - return UUID_PATTERN.test(candidate) && stem.length > 36 ? candidate : undefined; -} - -// The filename first, `session_id` only as the fallback for a payload carrying no transcript -// path: the two disagree on well over a third of rollouts, subagent threads most of all, and -// a vendor_id written from `session_id` there names another rollout and joins to nothing -// while the journal still looks healthy. -function readSessionId(payload) { - return codexSessionIdFromTranscriptPath(payload.transcript_path) ?? payload.session_id; -} - -module.exports = { - readSessionId, - codexSessionIdFromTranscriptPath, - readCwd: (payload) => payload.cwd, - // Measured on codex.sse_event. - vendorField: "conversation.id", - stepStart: { skillName: skillNameFromSkillFileRead, turnIdField: "turn_id" }, - writtenPath: null, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/copilot.cjs b/plugins/aidd-telemetry/hooks/lib/tools/copilot.cjs deleted file mode 100644 index 4c15407d5..000000000 --- a/plugins/aidd-telemetry/hooks/lib/tools/copilot.cjs +++ /dev/null @@ -1,37 +0,0 @@ -// Everything the journal knows about Copilot: two payload shapes for the same host (its -// canonical builder and the _vsCodeCompat one, see lib/host.cjs), so session id and step -// name each read behind both spellings. cwd is straight off the payload either way. No -// turn identifier ever arrives on a hook payload, and no written-path extractor - Copilot -// was never captured handing a path to a hook. - -const { skillNameFromArgument, skillNameFromAnyArgument } = require("./skill-detection.cjs"); - -module.exports = { - // sessionId is the canonical builder's spelling; session_id is the _vsCodeCompat - // builder's - both are Copilot's own, never a fallback guess. - readSessionId: (payload) => payload.sessionId ?? payload.session_id, - readCwd: (payload) => payload.cwd, - // Measured on the invoke_agent span. - vendorField: "gen_ai.conversation.id", - stepStart: { - // The canonical builder spells them toolName/toolArgs, toolArgs a JSON string. The - // _vsCodeCompat one keeps the "skill" tool name but delivers tool_input as an object, - // keyed like Claude Code's. Neither was guessed; both came from a captured payload. - skillName: skillNameFromAnyArgument([ - skillNameFromArgument({ - toolField: "toolName", - toolName: "skill", - argumentsField: "toolArgs", - nameField: "skill", - }), - skillNameFromArgument({ - toolField: "tool_name", - toolName: "skill", - argumentsField: "tool_input", - nameField: "skill", - }), - ]), - turnIdField: null, - }, - writtenPath: null, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/cursor.cjs b/plugins/aidd-telemetry/hooks/lib/tools/cursor.cjs deleted file mode 100644 index 0cc477d39..000000000 --- a/plugins/aidd-telemetry/hooks/lib/tools/cursor.cjs +++ /dev/null @@ -1,29 +0,0 @@ -// Everything the journal knows about Cursor: it names its working directory -// workspace_roots, never cwd, and a SKILL.md read is the only evidence of which step ran, -// with generation_id as the turn it belongs to. No written-path extractor - Cursor was -// never captured handing a path to a hook. Its own telemetry export is itself unmeasured -// (an Enterprise team setting nobody here can turn on), so vendorField names nothing. - -const { getRepoRoot } = require("../repo.cjs"); -const { skillNameFromSkillFileRead } = require("./skill-detection.cjs"); - -// The first workspace_roots entry getRepoRoot actually resolves - a multi-root workspace -// carries several entries and only some of them are git repositories, so index zero is not -// safe to assume. -function firstGitWorkspaceRoot(workspaceRoots) { - if (!Array.isArray(workspaceRoots)) return undefined; - for (const root of workspaceRoots) { - if (typeof root === "string" && root && getRepoRoot(root)) return root; - } - return undefined; -} - -module.exports = { - readSessionId: (payload) => payload.session_id, - readCwd: (payload) => firstGitWorkspaceRoot(payload.workspace_roots), - // A documented-but-uncaptured attribute name would be exactly the false figure this - // field exists to prevent. - vendorField: null, - stepStart: { skillName: skillNameFromSkillFileRead, turnIdField: "generation_id" }, - writtenPath: null, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/index.cjs b/plugins/aidd-telemetry/hooks/lib/tools/index.cjs deleted file mode 100644 index 146ace2a8..000000000 --- a/plugins/aidd-telemetry/hooks/lib/tools/index.cjs +++ /dev/null @@ -1,35 +0,0 @@ -// The only place that maps a host name to what the journal knows about it. Adding a host -// is adding a file beside these five plus one line below - never a new branch anywhere -// else, and never a new table alongside this one. - -const claudeCode = require("./claude-code.cjs"); -const codex = require("./codex.cjs"); -const copilot = require("./copilot.cjs"); -const cursor = require("./cursor.cjs"); -const opencode = require("./opencode.cjs"); - -const TOOLS_BY_HOST = Object.freeze({ - "claude-code": claudeCode, - codex, - copilot, - cursor, - opencode, -}); - -function toolFor(host) { - return TOOLS_BY_HOST[host] || null; -} - -// Read behind the host's own declaration, never one host's spelling promoted to a rule - -// the two facts every caller needs regardless of which other fact it is also after. -function readSessionId(host, payload) { - const tool = toolFor(host); - return tool ? tool.readSessionId(payload) : undefined; -} - -function readCwd(host, payload) { - const tool = toolFor(host); - return tool ? tool.readCwd(payload) : undefined; -} - -module.exports = { TOOLS_BY_HOST, toolFor, readSessionId, readCwd }; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs b/plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs deleted file mode 100644 index bd6aec7e8..000000000 --- a/plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs +++ /dev/null @@ -1,13 +0,0 @@ -// Everything the journal knows about OpenCode. It is not a stdin hook: opencode-plugin.js -// builds this payload itself, through the same session_id/cwd keys every stdin host uses, so -// the shape below stays one shape. It forwards no tool call, so there is no payload here for -// a step or a written path to be read from. Its own export is unmeasured, so vendorField -// names nothing, the same fact Cursor's entry states for the same reason. - -module.exports = { - readSessionId: (payload) => payload.session_id, - readCwd: (payload) => payload.cwd, - vendorField: null, - stepStart: null, - writtenPath: null, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/skill-detection.cjs b/plugins/aidd-telemetry/hooks/lib/tools/skill-detection.cjs deleted file mode 100644 index 8fff989b1..000000000 --- a/plugins/aidd-telemetry/hooks/lib/tools/skill-detection.cjs +++ /dev/null @@ -1,62 +0,0 @@ -// How a step's skill name is found, in general: neither shape below names a host, and each -// tool file passes in the field names its own payload uses. - -const { normalizeSeparators, stringsWithin } = require("../host.cjs"); - -// Anchored on a `skills/` segment, so an ordinary file named SKILL.md opens nothing. The -// tail accepts end-of-string or a quote/space, because on Codex the path sits inside a -// shell command line rather than alone in a field. -const SKILL_FILE_PATTERN = /(?:^|\/)skills\/([^/]+)\/SKILL\.md(?:["'\s]|$)/u; - -// Copilot delivers its tool arguments as a JSON string; Claude Code delivers an object. -function parseToolArguments(value) { - if (value && typeof value === "object") return value; - if (typeof value !== "string") return null; - try { - return JSON.parse(value); - } catch { - return null; - } -} - -// The argument family: the host names the skill outright, in a field of the tool call. -function skillNameFromArgument({ toolField, toolName, argumentsField, nameField }) { - return (payload) => { - if (payload[toolField] !== toolName) return null; - const args = parseToolArguments(payload[argumentsField]); - const name = args && args[nameField]; - return typeof name === "string" && name ? name : null; - }; -} - -// Runs several argument-family readers in sequence, first name found wins. For one host -// whose own builder produces more than one payload shape - both genuinely that host's, -// never a guess at a third - rather than a fallback chain crossing families. -function skillNameFromAnyArgument(readers) { - return (payload) => { - for (const reader of readers) { - const name = reader(payload); - if (name) return name; - } - return null; - }; -} - -// The path family: the host names no skill, and the only evidence is that it read a SKILL.md. -// Every string in the arguments is scanned rather than one named field, because Cursor puts -// the path in `file_path` while Codex buries it in a shell command - and Codex spells that -// tool differently in its hook and its transcript, so a tool name would match nothing. -function skillNameFromSkillFileRead(payload) { - for (const value of stringsWithin(payload.tool_input)) { - const match = SKILL_FILE_PATTERN.exec(normalizeSeparators(value)); - if (match) return match[1]; - } - return null; -} - -module.exports = { - SKILL_FILE_PATTERN, - skillNameFromArgument, - skillNameFromAnyArgument, - skillNameFromSkillFileRead, -}; diff --git a/plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs b/plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs deleted file mode 100644 index fed7f3d4c..000000000 --- a/plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs +++ /dev/null @@ -1,209 +0,0 @@ -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); - -/** - * Puts back the one line that makes a commit carry its session, when something removed it. - * - * The delegate script is ours outright and nobody regenerates it; the line calling it from - * `prepare-commit-msg` is the fragile half. This declines wherever the CLI itself would have - * declined — where a hook manager owns that file — or the delegate gains a second caller per - * commit, the manager's job and a direct call nobody installed. - * - * It never asks why the line is gone, which is what lets one path cover every cause. The - * managers are named in one place only, as root marker filenames, so nothing here rots when - * either changes the format of what it generates. - * - * `aidd telemetry off` deletes the delegate and this only runs while the delegate is present, - * so nothing is ever resurrected after `off`. - */ - -/** Both are also the CLI's, spelled again here because this file is zero-dependency - * CommonJS shipped into a person's repository and cannot import from a TypeScript program - * that may not be installed. A test compares what this repairs against what the CLI's own - * function produces, never against a second copy typed into a fixture. */ -const DELEGATE_FILE = "aidd-session-trailer.sh"; -const HOOK_FILE = "prepare-commit-msg"; -const HOOK_HEADER = "#!/bin/sh"; - -/** Separators forced to `/`, for the reason the CLI states at the same seam: a hook is run - * by the `sh` Git for Windows ships, and inside double quotes that shell treats a backslash - * as an ordinary character, so `C:\\Users\\…` names nothing while `C:/Users/…` resolves. */ -function hookLine(delegatePath) { - return `sh "${delegatePath.replace(/\\/gu, "/")}" "$@"`; -} - -/** - * Every spelling lefthook accepts for its own config, in the order it looks for them, then - * husky's directory. Mirrors the CLI's own list, spelled again for the reason `DELEGATE_FILE` - * is, and pinned to that list by scripts/__tests__/aidd-telemetry-trailer-repair.test.js. - */ -const HOOK_MANAGER_MARKERS = Object.freeze([ - "lefthook.yml", - "lefthook.yaml", - ".lefthook.yml", - ".lefthook.yaml", - ".husky", -]); - -/** - * From the root's marker files alone, never from the hook's contents: a manager regenerates - * that file, so by the time anything reads it the append is gone and the marker is all that - * is left. The root is the worktree's own, not the git directory — in a linked worktree only - * the first is where a manager's config sits. - */ -function hookManagerOwns(repoRoot) { - if (typeof repoRoot !== "string" || repoRoot === "") return false; - return HOOK_MANAGER_MARKERS.some((marker) => fs.existsSync(path.join(repoRoot, marker))); -} - -/** - * Answers what it did, in a word, for the tests: they are the only reader that needs to tell - * "declined" from "nothing to do" without inspecting the filesystem twice. - * - * `"no-delegate"` nothing to call, so nothing to repair — the state `off` leaves - * `"manager-owned"` a hook manager owns the file and already reaches the delegate itself - * `"not-ours-to-write"` the hook is version-controlled or a symlink; see `isOursToWrite` - * `"present"` the hook already calls it - * `"repaired"` the line was missing and has been put back - * `"unwritable"` the hook could not be read or written; a session is not the place to - * fail over this, and `check` is where a person is told - * - * Never throws. This runs inside a hook, and a hook that throws is a session that reports an - * error for something no session did. - */ -function repairCommitTrailerHook(hooksDir, gitDir, repoRoot) { - if (typeof hooksDir !== "string" || hooksDir === "") return "no-delegate"; - if (!fs.existsSync(path.join(hooksDir, DELEGATE_FILE))) return "no-delegate"; - - if (hookManagerOwns(repoRoot)) return "manager-owned"; - // The physical directory, since the guard compares realpaths: a line built from the - // unresolved spelling names a path the guard never approved, which on macOS put two call - // sites in one hook and ran the delegate twice per commit. - const resolved = realPath(hooksDir); - if (resolved === null || !isOursToWrite(resolved, gitDir)) return "not-ours-to-write"; - - const delegatePath = path.join(resolved, DELEGATE_FILE); - const hookPath = path.join(resolved, HOOK_FILE); - // A symlink is somebody's deliberate indirection, and every write here follows one — - // which would edit whatever it points at, most usefully a file the team shares. - if (isSymbolicLink(hookPath)) return "not-ours-to-write"; - - // `rename` needs the directory, not the file, so without this a `0444` hook is silently - // replaced and left reading `0444`. Asked before anything is written. - if (fs.existsSync(hookPath) && !isWritable(hookPath)) return "unwritable"; - - const line = hookLine(delegatePath); - try { - const existing = fs.existsSync(hookPath) - ? fs.readFileSync(hookPath, "utf8") - : `${HOOK_HEADER}\n`; - if (existing.includes(line)) return "present"; - return write(hookPath, `${existing}${existing.endsWith("\n") ? "" : "\n"}${line}\n`); - } catch { - return "unwritable"; - } -} - -/** - * Staged beside the target and renamed over it where possible, written directly where not. - * Renaming stops a session starting at the same moment from reading a half-written hook, but - * staging needs write permission on the directory: a `0555` hooks directory holding a - * writable hook takes the direct write and refuses the staged one. - * - * The mode is carried across deliberately: `rename` replaces the inode, so a `0700` hook - * would otherwise come back `0755`, widening a third party's file. - */ -function write(hookPath, content) { - const mode = modeOf(hookPath); - const staging = `${hookPath}.aidd-${process.pid}`; - try { - // No test can fail for this mode — `chmod` below sets the same bits — but it narrows the - // window in which the staging file is wider than the hook it replaces. - fs.writeFileSync(staging, content, { mode }); - // `open(2)` applies the umask to the mode it is given, so a `0770` hook comes back - // `0750`. `chmod` is not filtered, and is what actually carries the mode across. - fs.chmodSync(staging, mode); - fs.renameSync(staging, hookPath); - return "repaired"; - } catch { - // Never leave the staging file behind: one per session, each under a different pid, in - // the directory a person opens when something is wrong. Reachable only on Windows, where - // renaming over a file another process holds open fails EPERM. - try { - fs.unlinkSync(staging); - } catch { - // Nothing to clean up, which is the ordinary case when the write itself failed. - } - } - try { - fs.writeFileSync(hookPath, content, { mode }); - return "repaired"; - } catch { - return "unwritable"; - } -} - -/** Never widened: a file that was not executable stays that way, and `check` reports it - * rather than this quietly fixing it. */ -function modeOf(hookPath) { - try { - return fs.statSync(hookPath).mode & 0o777; - } catch { - return 0o755; - } -} - -/** - * Only a hooks directory physically inside the repository's own git directory. `core.hooksPath` - * may point into the working tree — a checked-in `.githooks/` shared with a team — where - * appending dirties a tracked file with a machine-absolute path on every session start. - * - * Physically, through `realpath`, never lexically: `ln -s ../.githooks .git/hooks` is inside - * the git directory by string and inside the working tree on disk, and it defeats both a - * `resolve` containment test and an `lstat` on the hook reached through the link. - * - * A path that cannot be resolved declines: this guard's failure direction is "do not". - */ -function isOursToWrite(hooksDir, gitDir) { - if (typeof gitDir !== "string" || gitDir === "") return false; - const inside = realPath(gitDir); - const target = realPath(hooksDir); - if (inside === null || target === null) return false; - return target === inside || target.startsWith(inside + path.sep); -} - -function realPath(target) { - try { - return fs.realpathSync(target); - } catch { - return null; - } -} - -function isWritable(target) { - try { - fs.accessSync(target, fs.constants.W_OK); - return true; - } catch { - return false; - } -} - -function isSymbolicLink(target) { - try { - return fs.lstatSync(target).isSymbolicLink(); - } catch { - return false; - } -} - -module.exports = { - repairCommitTrailerHook, - hookLine, - DELEGATE_FILE, - HOOK_FILE, - HOOK_HEADER, - HOOK_MANAGER_MARKERS, -}; diff --git a/plugins/aidd-telemetry/hooks/opencode-plugin.js b/plugins/aidd-telemetry/hooks/opencode-plugin.js deleted file mode 100644 index 96041c814..000000000 --- a/plugins/aidd-telemetry/hooks/opencode-plugin.js +++ /dev/null @@ -1,157 +0,0 @@ -// OpenCode's own extension surface: a JS module it loads in-process through its -// `{plugin,plugins}/*.{ts,js}` auto-discovery convention, never a hook it spawns per event. -// -// The export shape is load-bearing, not style: OpenCode's loader only recognises a genuine -// ESM export. A CommonJS `module.exports` file sits in the auto-discovery path, is logged as -// found, and never runs a line of its own code - no error, no output. -// -// A second limit rules out reusing journal.cjs's functions in-process: OpenCode's loader -// cannot see a local CommonJS file's exports at all, so `await import("./lib/record.cjs")` -// resolves to an empty namespace while a genuine ESM sibling imports fine. So this spawns -// `journal.cjs` as a child over the same stdin-JSON contract every other host's hook uses, -// naming itself so `detectHost` recognises it without guessing at a fifth vendor shape. -// -// `node ` is not a valid invocation - Node treats the string as a module -// specifier resolved against its own cwd - so fileURLToPath is what makes the spawn work. -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { setupOpencodeEvents } from "../hooks/opencode-events.js"; - -// Not a sibling: OpenCode's loader scans `plugin/` one level deep, so the build delivers this -// module there alone and every other hook script under `hooks//`. That is the same -// `../hooks//` the generated bridge resolves (opencode-hooks-bridge.ts's HOOKS_DIR), -// and build.unit.test.ts checks this literal against where the build actually writes it. -const JOURNAL_SCRIPT = fileURLToPath( - new URL("../hooks/aidd-telemetry/journal.cjs", import.meta.url) -); - -// Never `process.execPath`: OpenCode ships as its own standalone binary, so that path names -// `opencode` itself, not a Node runtime that can run journal.cjs. -// -// This runs in-process, not as a host-spawned hook OpenCode expects to wait on, so `timeout` -// bounds the block: a `journal.cjs` that hangs must not freeze OpenCode's event loop, and a -// killed run is one missed measurement. -function runJournal(event, payload) { - spawnSync("node", [JOURNAL_SCRIPT, event], { - input: JSON.stringify(payload), - encoding: "utf8", - timeout: 5000, - }); -} - -// V2's adapter delivers `session.created` with `info.directory`; retain it per session. -// V1 did not deliver that event, so its first journal call uses and retains the factory's -// directory, valid for the observed single-directory `opencode run` path. -const directoryBySessionId = new Map(); - -// Mirrors `lib/task-declared.cjs`'s own `TASK_PATH_PATTERN`, duplicated rather than -// imported: OpenCode's loader cannot see a local CommonJS file's exports at all, which is the -// same constraint that makes this plugin spawn `journal.cjs` rather than call into it. -const TASK_PATH_PATTERN = - /aidd_docs\/tasks\/\d{4}_\d{2}\/[^/"'\s]+\/[^"'\s]*|aidd_docs\/tasks\/\d{4}_\d{2}\/[^/"'\s]+\.md/u; - -// Every completed tool part reaches this hook, most naming no task, and a `spawnSync` per -// call blocks OpenCode's own event loop. Cheap and conservative: `true` only when a string -// inside `toolInput` could possibly be a declared path, which is what `declaredTaskPath` -// tests after the spawn, so this can never skip a call that would have been acted on. -function mightDeclareATask(toolInput) { - if (typeof toolInput === "string") { - return TASK_PATH_PATTERN.test(toolInput.replace(/\\/gu, "/")); - } - if (!toolInput || typeof toolInput !== "object") return false; - return Object.values(toolInput).some(mightDeclareATask); -} - -// Only `completed` is read: `pending` carries an empty, unsearchable `input`, and reading -// `running` too would declare the same task twice for one call. -// -// A tool part carries no task identity of any kind - only the tool's name and `state.input`, -// the call's own arguments, which is exactly the shape `declaredTaskPath` already reads on -// every other host as `tool_input`. -// -// On OpenCode `tool-used` does exactly one thing downstream, task declaration, so a call this -// pre-filter refuses would have done nothing after the spawn either. -function declaredTaskCallFor(event, sessionDirectories, fallbackDirectory) { - const part = event.properties.part; - if (part?.type !== "tool" || part.state?.status !== "completed") return null; - if (!mightDeclareATask(part.state.input)) return null; - const sessionId = event.properties.sessionID; - const cwd = event.properties.directory ?? sessionDirectories.get(sessionId) ?? fallbackDirectory; - return { - script: "tool-used", - payload: { tool: "opencode", session_id: sessionId, cwd, tool_input: part.state.input }, - }; -} - -/** Kept separate from `runJournal`'s spawn so a captured event can be asserted against - * without running node as a child process. - * - * Reached as a property of the plugin below, never as a second named export: OpenCode's - * loader calls every function-valued export of a file in `plugin/` as a plugin factory of its - * own, and doing that to this function left `opencode run` dead before any session started. */ -function journalCallFor(event, sessionDirectories, fallbackDirectory) { - if (event.type === "session.created") { - const sessionId = event.properties?.info?.id; - const cwd = event.properties?.info?.directory; - sessionDirectories.set(sessionId, cwd); - return { script: "session-start", payload: { tool: "opencode", session_id: sessionId, cwd } }; - } - if (event.type === "session.idle") { - const sessionId = event.properties.sessionID; - const cwd = - event.properties.directory ?? sessionDirectories.get(sessionId) ?? fallbackDirectory; - return { script: "turn-end", payload: { tool: "opencode", session_id: sessionId, cwd } }; - } - if (event.type === "message.part.updated") { - return declaredTaskCallFor(event, sessionDirectories, fallbackDirectory); - } - return null; -} - -/** Nothing on OpenCode's bus guarantees `properties` is set, and an event this plugin does - * not act on is read here before any per-type dispatch would skip it, so a missing field must - * resolve to `undefined` rather than throw. */ -function sessionIdOf(event) { - if (event.type === "session.created") return event.properties?.info?.id; - return event.properties?.sessionID; -} - -/** V1 fallback: open an unannounced session before its first journal call, using that call's - * directory. V2's adapter delivers `session.created`, so announced sessions pass through - * without a duplicate opening. */ -function journalCallsFor(event, sessionDirectories, fallbackDirectory) { - const sessionId = sessionIdOf(event); - const announced = sessionId !== undefined && sessionDirectories.has(sessionId); - const call = journalCallFor(event, sessionDirectories, fallbackDirectory); - if (call === null) return []; - if (announced || call.script === "session-start") return [call]; - sessionDirectories.set(sessionId, call.payload.cwd); - const opening = { tool: "opencode", session_id: sessionId, cwd: call.payload.cwd }; - return [{ script: "session-start", payload: opening }, call]; -} - -export const AiddTelemetry = async (input) => ({ - event: async ({ event }) => { - // The rule journal.cjs's own main() states, applied where OpenCode calls this in-process - // instead of spawning it: a measurement layer that breaks a session is worse than one - // that misses one, and whatever throws here is this plugin's fault, never the person's. - try { - for (const call of journalCallsFor(event, directoryBySessionId, input.directory)) { - runJournal(call.script, call.payload); - } - } catch { - // Silent on purpose - see above. - } - }, -}); - -// The spawn-free test seams, hung off the one export rather than standing beside it — see -// journalCallFor's own comment for why a second export is ruled out. -AiddTelemetry.journalCallFor = journalCallFor; -AiddTelemetry.journalCallsFor = journalCallsFor; - -export default { - id: "aidd-telemetry", - server: AiddTelemetry, - setup: (ctx) => setupOpencodeEvents(ctx, AiddTelemetry), -}; diff --git a/plugins/aidd-telemetry/skills/00-init/SKILL.md b/plugins/aidd-telemetry/skills/00-init/SKILL.md deleted file mode 100644 index 023d8d121..000000000 --- a/plugins/aidd-telemetry/skills/00-init/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: 00-init -description: Turns AIDD measurement on for a project, proves it is recording, lets a person opt into (or out of) naming themselves on their own records, and removes what was measured when asked. Use when the user wants to start measuring what their work costs, wants to stop, asks why nothing is being recorded, wants their own name to appear on (or disappear from) what gets measured, or wants their measured data deleted. Not for answering what a piece of work consumed. -argument-hint: project ---- - -# Init - -```mermaid -flowchart LR - ask([project]) --> check --> enable --> verify - check -.->|"already on"| verify - verify --> recording([recording]) -``` - -A second, independent choice belongs to the person rather than the project: whether their -own records carry an identifier at all. - -```mermaid -flowchart LR - ask2([this person]) --> identify - identify -.->|"already chose"| status1([relayed, unchanged]) - identify -->|"opted in"| withdraw([forget: withdraw naming]) - withdraw -.->|"never opted in"| status2([relayed, unchanged]) -``` - -A third, independent ask reaches the same action, and needs neither of the two above: -removing what this tool measured, wholly, on this machine. - -```mermaid -flowchart LR - ask3([asks to be forgotten]) --> shown([forget: shown what would go, and what history keeps]) - shown -->|"confirms"| removed([removed, in counts shown]) - shown -.->|"declines"| status3([nothing removed]) -``` - -## Actions - -Run the flow above. Read only the next action file. - -| Action | Does | -| -------- | -------------------------------------------------------- | -| check | confirm the CLI and read the current switch | -| enable | ask, then turn measurement on | -| verify | prove a session is actually being recorded | -| identify | ask this person, then attach their own identifier, and offer to link another identifier as the same one | -| forget | withdraw naming, or remove everything this tool measured — two different asks, one action | - -## Transversal rules - -- Measuring someone's project is theirs to allow. Ask before turning it on, always. -- Naming a person is theirs alone to allow, separately from the project switch above — never assumed from the project being measured, never asked on someone else's behalf. -- Run only `aidd telemetry on --yes`, `aidd telemetry off`, `aidd telemetry identity`'s own verbs, and `aidd telemetry forget`'s own verbs (preview first, `--yes` only after this person has seen it and confirmed). Never a script, and never a command belonging to another skill. -- `aidd telemetry identity` never reads `.aidd/config.json` or `AIDD_USER_CONFIG_DIR` — both are settings a repository or a CI job can set, and this choice is not theirs to make. It reads and writes only this machine's own user profile. -- Removing what was measured is irreversible, and reaches further than one project: this machine's stored records span every project ever measured on it, not only the one being discussed. Never presented as reversible, and never presented as scoped to one project when it is not. -- What history keeps is relayed exactly as `aidd telemetry forget` states it — a journal already committed as certainly held, one merely staged but never committed as not yet held, one not tracked at all as possibly held — and is never presented as removable. No command in this skill rewrites git history. -- The `aidd` command cannot be found: say so and change nothing. diff --git a/plugins/aidd-telemetry/skills/00-init/actions/01-check.md b/plugins/aidd-telemetry/skills/00-init/actions/01-check.md deleted file mode 100644 index 581966add..000000000 --- a/plugins/aidd-telemetry/skills/00-init/actions/01-check.md +++ /dev/null @@ -1,44 +0,0 @@ -# 01 - Check what is already set up - -Confirm this machine can turn measurement on at all, and read whether the project already -does. - -## Output - -A confirmed `aidd` command, and whether the switch is already on. - -## Process - -1. **Require the CLI.** Turning measurement on writes the one switch every component reads; - that work lives in the `aidd` command, not in a script beside this skill. - - ```bash - aidd --version - ``` - - No output, or a command that is not found, means this machine cannot answer. **Stop, and - say two things:** - - the `aidd` command is required to answer, and can be installed with - `npm install -g @ai-driven-dev/cli`; - - **recording is unaffected** - the hooks that write the journal are plain node and need - nothing installed, so no measurement is being lost while the CLI is missing. - - Never continue to a report. A missing command is not a measurement of zero, the same way - a tool that writes no token count is not a tool that cost nothing. - -2. **Read the switch.** Read `telemetry.enabled` from `.aidd/config.json`. - - Already `true`: no consent to ask again, but run `aidd telemetry on --yes` once more - anyway — idempotent on the switch itself, and it is what catches a project turned on - before this check existed up on ignoring the journal and naming any of it git already - tracks. The `--yes` here confirms nothing new: this project already made this exact - choice, and re-running only catches it up. Relay what it prints, then go to verify. - - Absent or `false`: go to enable. - -## Test - -| Case | Pass | -| --- | --- | -| `aidd` answers | the run continues to enable or verify | -| `aidd` is absent | the run stops, names the CLI, and states that recording is unaffected | -| `aidd` is absent | no switch is written, and nothing is asked | -| The switch is already on | the run goes to verify without asking again, but still catches the journal up on `.gitignore` and names anything already tracked | diff --git a/plugins/aidd-telemetry/skills/00-init/actions/02-enable.md b/plugins/aidd-telemetry/skills/00-init/actions/02-enable.md deleted file mode 100644 index 3f1a27f56..000000000 --- a/plugins/aidd-telemetry/skills/00-init/actions/02-enable.md +++ /dev/null @@ -1,32 +0,0 @@ -# 02 - Ask, then turn measurement on - -Get the user's agreement, then flip the one switch every component reads. - -## Input - -A confirmed `aidd` command, from check. - -## Output - -`.aidd/config.json` carrying `telemetry.enabled: true`, and a user who knows what it records. - -## Process - -1. **Say what it records, before asking.** It writes into `aidd_docs/runs/` which session served which task and which skill was running when. It records no prompt, no code, no diff, and no person's name or address. Nothing leaves the machine. Also say that `.aidd/config.json` — the file this is about to write — is git-tracked, so this turns measurement on for everyone who clones this repository, not only for this person. -2. **Ask.** Wait for a yes. - - The user declines: stop, and write nothing. -3. **Turn it on.** Run `aidd telemetry on --yes` — the `--yes` confirms the consequence just explained, which merges into whatever the config already holds. The same run also adds `aidd_docs/runs/` to `.gitignore` — the journal belongs to this repository and is never offered to a commit. -4. **Relay what it prints about existing history.** If `aidd telemetry on --yes` names files under `aidd_docs/runs/` already tracked by git, say so plainly: the repository, the task folders written into, the skills run, their timings, and every file each session wrote is in that history now — no person's name or address. Nothing is removed or rewritten — what to do about it is the user's call, not this skill's. -5. **Say what it cannot recover.** The journal starts now, so sessions that already ran carry no step and no task and will read as unattributed. -6. **Say it is reversible, and what reversing keeps.** `aidd telemetry off` stops the recording from that moment; sessions already measured stay measured and still report. - -## Test - -| Case | Pass | -| --- | --- | -| The user agrees | `telemetry.enabled` is true and every other key survives | -| The user declines | the config file is unchanged | -| The config already held other keys | those keys are still there afterwards | -| Turned off after a session was measured | that session still reports the same figures | -| The journal is not yet in `.gitignore` | it is added, and nothing wider | -| A journal file is already tracked by git | the user is told what it contains; nothing is removed or rewritten | diff --git a/plugins/aidd-telemetry/skills/00-init/actions/03-verify.md b/plugins/aidd-telemetry/skills/00-init/actions/03-verify.md deleted file mode 100644 index 5042fa84b..000000000 --- a/plugins/aidd-telemetry/skills/00-init/actions/03-verify.md +++ /dev/null @@ -1,27 +0,0 @@ -# 03 - Prove it is recording - -Check that a session is really being journalled, rather than trusting that the switch was enough. - -## Input - -The project, with its switch on. - -## Output - -Evidence that a session is recorded, or a named reason why none is. - -## Process - -1. **Look for a run file.** Run `ls aidd_docs/runs/*.jsonl`. - - None, and the switch was just turned on: expected, since the hook writes at the next session start. Ask for a new session and stop, without reporting a failure. - - None, and the switch has been on a while: the hook is not running, which is the host tool failing to register the plugin's hooks rather than a measurement problem. -2. **Read one back.** A run file's first line names the tool, the project and the session, and its `step_start` lines name the skills that have run. -3. **Hand over.** Answering what those sessions consumed belongs to another skill, and this one reports no figures. - -## Test - -| Case | Pass | -| --- | --- | -| Just turned on | it asks for a new session and reports no failure | -| A session has run | it shows the run file and what that file names | -| On a while with no run file | it names the unregistered hook, not the measurement | diff --git a/plugins/aidd-telemetry/skills/00-init/actions/04-identify.md b/plugins/aidd-telemetry/skills/00-init/actions/04-identify.md deleted file mode 100644 index 8ecab87a7..000000000 --- a/plugins/aidd-telemetry/skills/00-init/actions/04-identify.md +++ /dev/null @@ -1,69 +0,0 @@ -# 04 - Let a person choose to be named - -Ask this person, on their own machine, whether records this machine reads locally should -carry their own identifier. Measurement being on says nothing about this — it is a -separate choice, made or declined independently of the project switch. - -## Input - -A confirmed `aidd` command, from check. - -## Output - -Either nothing changed, or a user-scoped `identity.json` holding a fresh identifier this -person can withdraw at any time, and a user who knows exactly what it does and does not do. - -## Process - -1. **Check first.** Run `aidd telemetry identity`. - - Already on: relay it and stop — no consent to ask again for a choice already made. -2. **Say what it attaches, before asking.** Turning this on attaches one stable, random - identifier — never an email, a git author, or a hostname — to records this machine reads - locally, from the moment it is turned on. It never reaches the run journal, never applies - to a session already recorded, and never leaves this machine — there is no route in this - plugin that sends a record, identified or not, anywhere else. A display name is a - separate, later choice; turning this on alone sets none. -3. **Ask.** Wait for a yes. - - Declines: stop, and write nothing. -4. **Turn it on.** Run `aidd telemetry identity use` and relay what it prints. No identifier means mint one; an identifier taken from another machine goes after it. -5. **Offer a display name, separately.** Ask whether they also want a display name shown - beside their figures. Only on a yes, ask for the value and run - `aidd telemetry identity use --name ""`. A no here is not a smaller yes to the - identifier question — nothing beyond the identifier is set. -6. **Say it is reversible.** `aidd telemetry identity off` stops new records carrying - it; what is already stored keeps the identifier it was written with. -7. **On a second machine, offer to take the same identity rather than mint a new one.** - The ordinary way to be one person across machines is `aidd telemetry identity use - ` — taking the identifier this person already holds elsewhere, so both - machines' records fold into one row. Taking the identifier already in effect here is - reported as already in effect, not written twice; taking a different one replaces this - machine's own identifier and says so, and records already written keep the identifier - they were written with. -8. **Offer to link, only for an identifier this person cannot simply take as their own** — - a tool's own pseudonymous identifier, or one kept from before a withdrawal. Running - `aidd telemetry identity link ` adds it onto this person's identity: both fold - into one row in every report from then on, instead of printing as two. `link` is a - declaration the CLI cannot verify - only offer it for an identifier this person actually - owns, never one seen on someone else's record. This writes onto the same `identity.json` - `use` writes (under this machine's own user profile, never `.aidd/config.json` - or `AIDD_USER_CONFIG_DIR`) — refused before there is an identity to add onto. Linking one - already listed reports it as already listed, not as a second write. - `aidd telemetry identity unlink ` removes one identifier without touching this - person's own identity — reports go back to counting it unresolved. Unlinking one nobody - listed reports nothing to remove, never a failure. - -## Test - -| Case | Pass | -| --- | --- | -| The user agrees | `identity.json` holds a fresh `person_id` with `origin: "minted"`; the run's output says what it does and does not attach to | -| The user declines | no file is created | -| Already identified | it relays the existing identity and does not ask again | -| The user declines a display name | the identifier is still set; no display name is | -| Repository or CI cannot make this choice | `aidd telemetry identity` reads only this machine's own profile, never `.aidd/config.json` or `AIDD_USER_CONFIG_DIR` | -| A second machine takes the same identity | `identity.json` there records `origin: "adopted"`; a report folds both machines into one row | -| The identifier already in effect is taken again | reported as already in effect, writing nothing | -| An identifier this person cannot choose is linked | `identity.json`'s own `also_me` lists it; a report folds both into one row | -| An identifier already listed is linked again | reported as already listed, not written twice | -| A linked identifier is unlinked | `also_me` no longer lists it; a report counts it unresolved again | -| An unlisted identifier is unlinked | reported as nothing to remove, not as a failure | diff --git a/plugins/aidd-telemetry/skills/00-init/actions/05-forget.md b/plugins/aidd-telemetry/skills/00-init/actions/05-forget.md deleted file mode 100644 index b5978fc8f..000000000 --- a/plugins/aidd-telemetry/skills/00-init/actions/05-forget.md +++ /dev/null @@ -1,65 +0,0 @@ -# 05 - Take it back - -Two different asks reach here, and neither requires the other: stop new records carrying -this person's identifier, or remove everything this tool measured. A person who never -opted into naming themselves can still ask for the second. - -## Input - -A confirmed `aidd` command, and which of the two this person is asking for. - -## Output - -Either the user-scoped `identity.json` removed (naming withdrawn only, nothing already -stored touched), or this project's run journal, this machine's stored records, and this -machine's identity file all gone (everything measured removed) — in both cases, a person -who knows exactly what happened, what stayed, and what no command here can touch. - -## Process - -1. **Tell the two apart, if not already clear.** Ask which this person means: - - **Withdraw naming** — stop attaching their identifier to new records. Opting in - again later mints a fresh identifier; nothing already stored is touched or deleted. - - **Remove everything measured** — irreversible. This project's run journal, this - machine's stored records (spanning every project ever measured on this machine, not - only this one), and this machine's identity file are all deleted. - -2. **Withdraw naming.** Run `aidd telemetry identity`. - - Already off: relay it and stop. - - Otherwise, run `aidd telemetry identity off` and relay everything it prints: new - records carry no person from this moment; records already stored keep the identifier - they were written with, unchanged — nothing is deleted or rewritten in the sink - itself; and opting in again later mints a fresh identifier, never the one just - withdrawn. - -3. **Remove everything measured.** - 1. Run `aidd telemetry forget` (without `--yes`) and relay exactly what it shows: every - location it names, roughly how much is in each, and what history keeps. A journal - already committed is relayed as certainly held by history; one merely staged but - never committed is relayed as not yet held — history holds nothing for it until it - is actually committed; one not tracked at all is relayed as possibly held, if it was - ever committed before — never as an all-clear. Never say history is removed, or offer - to remove it: no command here rewrites git history. - 2. Nothing was ever measured: relay that and stop — there is nothing to confirm. - 3. Ask this person to confirm, having seen exactly what would go and what stays out of - reach. - - Declines: stop, and say plainly that nothing was removed. - 4. Run `aidd telemetry forget --yes` and relay what it reports: what went, in the same - counts already shown; what did not, per thing, with why; and that the telemetry - switch itself was untouched — measurement can be turned on again with - `aidd telemetry on --yes`. - -## Test - -| Case | Pass | -| --- | --- | -| Withdraw naming, was identified | `identity.json` is removed; the message names what stays and what starts fresh | -| Withdraw naming, was never identified | nothing changes, and it says so rather than erroring | -| Withdraw naming | records already stored are untouched — this path only stops what happens next | -| Remove everything, confirms | the run journal, the sink and the identity file are all gone; the counts relayed match what the preview showed | -| Remove everything, declines | nothing is removed, and it is said plainly, not as an error | -| Remove everything, nothing was ever measured | it says so and asks nothing | -| Remove everything, a committed journal | history is relayed as certainly held, never as removable | -| Remove everything, a staged-but-never-committed journal | history is relayed as not yet held, never as certainly held | -| Remove everything, an untracked journal | history is relayed as possibly held, never as an all-clear | -| Remove everything | the telemetry switch is untouched; a later `aidd telemetry on --yes` still works | diff --git a/plugins/aidd-telemetry/skills/01-cost/SKILL.md b/plugins/aidd-telemetry/skills/01-cost/SKILL.md deleted file mode 100644 index 4892ae2b2..000000000 --- a/plugins/aidd-telemetry/skills/01-cost/SKILL.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: 01-cost -description: Answers what a period or one task consumed - a total, a day-by-day series, or a breakdown by step, model, task, backlog item, orchestrated flow, tool, project or person - and hands back the artefact each question deserves. Use when the user asks what a piece of work cost, what changed, where the effort went, which task, which backlog item or ticket, which orchestrated run, for which project, or who spent it. Not for turning measurement on. -argument-hint: task | period ---- - -# Cost - -```mermaid -flowchart LR - ask([task or period]) --> locate --> collect --> report - locate -.->|"not measuring"| stopped([stopped]) - collect -.->|"nothing journalled"| stopped - report --> answer([answer]) -``` - -## Actions - -Run the flow above. Read only the next action file. - -| Action | Does | -| ------- | -------------------------------------------------- | -| locate | find the script and check the switch | -| collect | read what each tool's own files hold | -| report | choose the axis the question needs, then answer with the artefact it deserves | - -## The question, not the flag - -Someone asking what last month cost does not know which axis answers them. Read the -question, offer these axes in its own language, and derive the flags - never hand back a -menu and ask them to pick. - -| The question sounds like | Axis | Artefact | -| --- | --- | --- | -| what did this cost, how much did it use | total | one total, in a line | -| what changed, which day spiked | day | a series, one row per day | -| where did it go, which step, model, tool or project took it | step, model, tool or project | a breakdown table | -| which framework task | task | one row per task declared in the period, plus the remainder with no task declared | -| per backlog item, per ticket, what did issue X cost | backlog | one row per backlog item a task declared, plus tasks that named none, plus the remainder with no task at all | -| per orchestrated run, per flow, what did that pipeline cost | flow | one row per orchestrated run the journal's own sequence names, plus the remainder that ran outside any flow | -| which subagent, what did delegation cost | agent | one row per agent that ran, plus the main thread's own row | -| which prompt, which turn, what did one request cost | prompt | one row per prompt that caused work, dated, plus the row for records naming none | -| for a report, to paste, to send, to keep | any of the above | the same artefact, written to a file | -| per person, who spent, which teammate | person | one row per resolved person plus every unresolved identity, each with the raw identities behind it | - -## Transversal rules - -- Answer only through `aidd telemetry read` and `aidd telemetry report`. Never a script beside this skill: the report is computed in one place, and that place is the CLI. -- Report what the script printed. Recomputing a figure a second way is how two figures start disagreeing. -- An absent number is not a zero. Say the figure is unknown and give what is known instead. -- Turning measurement on belongs elsewhere. Stop and say so rather than doing it here. -- The script cannot be found or fails: say so and show no figure. diff --git a/plugins/aidd-telemetry/skills/01-cost/actions/01-locate.md b/plugins/aidd-telemetry/skills/01-cost/actions/01-locate.md deleted file mode 100644 index a5d1e64f8..000000000 --- a/plugins/aidd-telemetry/skills/01-cost/actions/01-locate.md +++ /dev/null @@ -1,39 +0,0 @@ -# 01 - Require the CLI and check the switch - -Confirm this machine can answer at all, and that the project is measuring. - -## Output - -A confirmed `aidd` command, or a stop with the reason. - -## Process - -1. **Require the CLI.** Answering reads what your AI tools wrote and turns it into a report; - that work lives in the `aidd` command, not in a script beside this skill. - - ```bash - aidd --version - ``` - - No output, or a command that is not found, means this machine cannot answer. **Stop, and - say two things:** - - the `aidd` command is required to answer, and can be installed with - `npm install -g @ai-driven-dev/cli`; - - **recording is unaffected** - the hooks that write the journal are plain node and need - nothing installed, so no measurement is being lost while the CLI is missing. - - Never continue to a report. A missing command is not a measurement of zero, the same way - a tool that writes no token count is not a tool that cost nothing. - -2. **Read the switch.** Read `telemetry.enabled` from `.aidd/config.json`. - - Already `true`: go to collect. - - Absent or `false`: stop, and say the project is not measuring yet. - -## Test - -| Case | Pass | -| --- | --- | -| `aidd` answers | the run continues to collect | -| `aidd` is absent | the run stops, names the CLI, and states that recording is unaffected | -| `aidd` is absent | no report is produced, empty or otherwise | -| The switch is off | the run stops and writes nothing | diff --git a/plugins/aidd-telemetry/skills/01-cost/actions/02-collect.md b/plugins/aidd-telemetry/skills/01-cost/actions/02-collect.md deleted file mode 100644 index a9ab5721e..000000000 --- a/plugins/aidd-telemetry/skills/01-cost/actions/02-collect.md +++ /dev/null @@ -1,35 +0,0 @@ -# 02 - Collect what the tools already wrote - -Join each tool's own transcript to the run journal, and store the result. - -## Input - -The path to `aidd telemetry`, from locate. - -## Output - -A stored set of this project's sessions, and a note of anything unreadable. - -## Process - -1. **Read every journalled session.** Run `aidd telemetry read`, which needs no session identifier because the journal already holds every one of them. -2. **Read the answer, one line per tool.** Five answers, and only one of them is a zero. - - | Reads | Means | - | --- | --- | - | `read (N new of M)` | it found records, `N` of them new, since a re-read stores nothing twice | - | `read, nothing found` | it held the session and billed nothing, which is a real zero | - | `no session found` | it has no trace of the session, so nothing is known | - | `could not be read` | its reader failed, so nothing is known and something is wrong | - | `not covered` | nothing here can read that tool, and its reason follows on the line | - -3. **Carry a failure forward.** A tool reading `could not be read`, or a line ending in a count of sessions that could not be read, makes every figure that follows partial. -4. **Stop when nothing is journalled.** The output says so, which is the expected state before any session has run with measuring on. - -## Test - -| Case | Pass | -| --- | --- | -| Sessions have been journalled | it reports how many were read and what each tool gave | -| Run twice in a row | the second run stores nothing new | -| Nothing journalled yet | it says so and stops without inventing a figure | diff --git a/plugins/aidd-telemetry/skills/01-cost/actions/03-report.md b/plugins/aidd-telemetry/skills/01-cost/actions/03-report.md deleted file mode 100644 index d8bc252e4..000000000 --- a/plugins/aidd-telemetry/skills/01-cost/actions/03-report.md +++ /dev/null @@ -1,192 +0,0 @@ -# 03 - Choose the axis, and hand back the artefact it deserves - -Read the question, pick the axis it names (SKILL.md's table), ask the script for that -answer, and render only what that axis calls for - never one shape for every question. - -## Input - -The path to `aidd telemetry`, and the question the user asked, in their own words. - -## Output - -**One axis, asked for by name.** Run -`aidd telemetry report --axis --from --to `, -never alongside `--json` - the axis flag already picks the one rendering that answers the -question, printed exactly as the script wrote it: - -``` -period to [, task ] — axis: - - -``` - -Show it inline in the chat when the question named no destination. Write it to a file, -byte for byte what the script printed, when the question asked for a report, said to -paste, send, or keep it, or named a path outright - ask where, if it did not say. The -artefact's own first line already states its period and its axis, so it can still be -placed once the session that made it is gone. - -**Everything at once, read inline.** When the question is broad enough that no single row -of SKILL.md's table fits - "what did this cost" with nothing narrower, or "where did the -spend go" with no named axis - answer in this shape, filled from the object and nothing -else. - -```markdown -**** — to - -| | | -| --- | --- | -| Sessions | | -| Requests | | -| Tokens | (% cache) | -| Cost | | - - - -**How the ticket was known** - -| | Share | -| --- | --- | -| Declared by the flow | % | -| Inferred from a written file | % | - -**Where it went** - -| Step | Share | Tokens | Attribution | -| --- | --- | --- | --- | -| | % | | | - -**By model** - -| Model | Share | Tokens | -| --- | --- | --- | - -**By task** - -| Task | Share | Tokens | Attribution | -| --- | --- | --- | --- | -| | % | | | - -**By backlog item** - -| Backlog item | Share | Tokens | -| --- | --- | --- | -| | % | | - -**By flow** - -| Flow | Share | Tokens | -| --- | --- | --- | -| | % | | - - -``` - -A breakdown the object leaves empty is a section left out, never a table of zeroes. - -## Process - -1. **Choose the axis from the question**, using SKILL.md's table. A question that already - names one - "by day", "by project", "per model", "per person" - needs no more reading - than that. -2. **Ask, as one axis or as the whole object.** - - One axis: `aidd telemetry report --axis --from 2026-08-01 --to 2026-08-31`. - - Everything: `aidd telemetry report --from 2026-08-01 --to 2026-08-31 --json`, reading the shape from the report contract, `aidd_docs/product/cost-report-contract.md` in the framework repository. - - The figure will be kept or compared: give `--from` and `--to`, since `--days` resolves against today and two identical calls on two days cover two different periods. -3. **Refuse an unknown shape.** `cost_report_version` is `15` today, read from the `--json` - path - the `--axis` path prints text the script already built from that same object, so - there is no separate version to check there. The bump from `14` to `15` did not add a - breakdown: `by_person`'s `resolution` gained a fourth value, `this-machine`, for a record - that named nobody on a machine that has declared an identity. Report such a row as that - person - declaring an identity names past work too, not only what follows. The bump - from `13` to `14` did not add a - breakdown: the reasons a `by_task` or `by_backlog` row can carry gained a fifth, - `precedes-journal`, for a record older than everything its own session's journal - witnessed. Report it as what it is - work billed before that session opened a journal, - which a resumed transcript carries - never as the flow declaring its task late. The bump - from `12` to `13` added `by_prompt` - to the top-level breakdowns: the prompt that caused the work, the one breakdown no host - limit can leave empty, since the reader resolves the turn for itself rather than waiting - on a capture. Not the same as complete - a record stored before that resolution shipped - never gains one, so the row naming no prompt is a real quantity to read, never noise. The bump from `11` to `12` did not add a - breakdown either: `by_task`'s `attribution` stopped being always `declared`, so one task - can now hold two rows - one for what a declaration covered, one for what only a written - file names. The bump from `10` to `11` did not add a - breakdown: the reasons a `by_task` or `by_backlog` row can carry for a record in no task - gained a fourth, `"no-journal"`, which says no usable run journal reached that record's - session - a fact about the read, never the claim that the session declared no task. The - bump from `9` to `10` added `by_agent` to the top-level breakdowns: which agent ran, - which on Claude Code is where most of the spend is. Read its `attribution` before - reading a row that names no agent: `main-thread` is a tool that names agents saying this - record belongs to none of them, `not-stated` is a tool whose route never names one - - every Codex, Copilot and OpenCode record - and calling the second a main thread would - state a fact nothing observed. The bump from `8` to `9` added a - fourth value to `attribution`, `prompt-matched`. The bump from `7` to `8` added `by_flow` - to the top-level breakdowns: which orchestrated run the journal's own step sequence - already names (see the report contract, `aidd_docs/product/cost-report-contract.md`), - nothing newly captured for it. The bump from `6` to `7` added `by_backlog` - to the top-level breakdowns: every task's records regrouped by what its own folder - declares (see the report contract, `aidd_docs/product/cost-report-contract.md`), - never a second notion of which task a record belongs to. The bump from `5` to `6` did - not add a breakdown: what `by_task` gives for a record that fell in no declared - interval can now be more than one row, each carrying `reason` - naming which distinct - fact applies, so two different gaps are never read as one. The bump from `4` added `by_task` to the top-level breakdowns, grouped by the same - declared intervals `--task` already filters on - never by a written file, which could - place one session under two task rows at once. The bump from `3` to `4` added - `by_person` to the top-level breakdowns and `identity_unusable` to `read`. - - Anything else on the `--json` path: stop, rather than guessing which field means what. -4. **Fill the "everything" shape above from the object**, when that is the path taken. The - headline comes from `totals`, the steps from `by_step`, the models from `by_model`, the - tasks from `by_task`, the backlog items from `by_backlog`, and none of it needs - re-adding since every breakdown already sums to its total. - - A share is of cost when `totals.cost_micro_usd` is present, of tokens otherwise. Say which above the table. - - Include "How the ticket was known" only when `task` is present - `task_attribution` - otherwise does not exist on the object at all, never an empty array to render as zeroes. - - `by_task` is always present, unlike `task_attribution`: it groups the whole period by - whichever declared interval each record falls in, never by `--task`'s own - whole-session written-file route. Never confuse the two - "How the ticket was known" - answers a `--task` filter's own question, "By task" answers "which tickets did this - period touch". - - `by_backlog` answers a different question again: not which task, but which backlog - item - "issue #661 cost X", never "this task cost X". Two tasks declaring the same - item are already merged into one row; nothing here re-merges anything. - - `by_flow` answers "what did this orchestrated run cost" - unrelated to task or - backlog, and read with nothing new captured for it. Two runs of the *same* - orchestrating skill in one session are two rows, told apart by when each opened - - never merge them by name. Read `attribution` before comparing two rows: - `journal-interval` is a run the journal opened and closed, `tool-stated` is every run - of that skill only the record's own tool named, in a session whose journal opened no - flow at all - it names no opening moment, because a name is not a run. A skill a - person ran by hand while a flow was open is counted inside it, since the journal - cannot tell it from one the orchestrator itself invoked - say so if it changes how a - figure reads. -5. **Read `capability` before explaining an absent figure.** A tool that cannot supply a number and a session that consumed nothing look identical in the numbers. - - | False field | Means | - | --- | --- | - | `local_read.amount` | that tool's files carry no currency figure, true of every tool read locally today | - | `local_read.tool_stated_step` | the tool never names the running skill, so its steps come from the journal or from nothing | - | `journal_attributable` | the journal never names that tool's sessions, so a sweep never reaches them | - | `task_attributable` | a session on this tool cannot be traced to a task, so it is absent from a task report without having done nothing | - -6. **Keep `unattributed` as itself.** Nothing measured supports reading it as no step having run, and it is never a residual. -7. **Say when the answer is partial.** A non-zero `read.undated_records` or `read.unreadable_lines` means the total is incomplete, and the reasons are in this plugin's own README. The `--axis` path already carries this in its own last lines; the `--json` path carries it in `read`. -8. **Say when this project's own switch is off.** `measurement_enabled: false` on the `--json` path (a "switch is off" line in the `--axis` header) means these figures are not scoped to this project — the sink they come from is scoped to this person, across every project they ever measured. Relay both facts together: the switch is off here, and the figures shown are real, from wherever they were measured. Never read `false` as "these numbers are made up" or drop them for it. - -## Test - -| Case | Pass | -| --- | --- | -| A question names an axis | the artefact for that axis is printed, and nothing else | -| A question asks for a report, or to keep or send the figure | the artefact is written to a file, unchanged, stating its period and axis | -| A question is broad, naming no axis | the answer gives tokens, models, steps and tasks, and names the days it covered | -| A question asks per person | the `person` axis is used, one row per resolved person plus every unresolved identity | -| A question asks per framework task | the `task` axis is used, one row per task declared in the period plus the row for what declared none | -| A question asks per backlog item, or what a ticket cost | the `backlog` axis is used, one row per declared item, tasks that named none merged into their own row | -| A question asks per orchestrated run, or what a pipeline cost | the `flow` axis is used, one row per run the journal's sequence names, plus the row for work outside any flow | -| The same orchestrating skill ran twice in one session | two rows, told apart by when each opened, never merged into one | -| A tool carries no amount | the answer says unknown and never prints a currency zero | -| A tool is not covered | the answer gives its declared reason instead of a figure | -| The read was partial | the answer says so before giving the total | -| Two answers for the same period | they carry the same numbers in the same order | -| `measurement_enabled` is `false` | the answer says the project's switch is off, alongside the real figures, never in place of them | diff --git a/plugins/aidd-telemetry/skills/02-check/SKILL.md b/plugins/aidd-telemetry/skills/02-check/SKILL.md deleted file mode 100644 index 514839d55..000000000 --- a/plugins/aidd-telemetry/skills/02-check/SKILL.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: 02-check -description: States what is in place — where measurement is allowed from, whether an identity is attached, where records land, whether the recorder is declared — then answers whether AIDD measurement is actually recording, one independently verifiable line per claim. Use when the user doubts a figure, sees no run file appear, wants proof the chain is working, or wants to know what is already configured. Not for turning measurement on or answering what a period cost. -argument-hint: project ---- - -# Check - -```mermaid -flowchart LR - ask([project]) --> locate --> diagnose - diagnose --> inplace([what is in place]) - inplace -.->|"measurement off"| stopped([stopped]) - inplace -.->|"not a git repository"| stopped - inplace --> answer([four claims]) -``` - -## Actions - -Run the flow above. Read only the next action file. - -| Action | Does | -| -------- | ------------------------------------------- | -| locate | confirm the CLI | -| diagnose | run it, and present every line it printed | - -## Transversal rules - -- Checking that a hook fired is not the same as checking that a file exists. A run file with only `session_start` is not evidence of anything closed. -- Run only `aidd telemetry check`. Never a script, and never a command belonging to another skill. -- Present every printed line. A line this skill leaves out is a claim the user cannot check. -- What is in place is printed first and is never a claim — it appears whether or not measurement is on, and names the file behind every fact so the user can go and change it. -- `ok`, `FAIL` and `--` are three different answers. `--` means there was nothing to evaluate, not that the chain is healthy. -- A declaration that the recorder is set up is not proof it fired. Relay it as what it is — a fact about configuration, never a promise about behaviour. -- The `aidd` command cannot be found: say so and check nothing. diff --git a/plugins/aidd-telemetry/skills/02-check/actions/01-locate.md b/plugins/aidd-telemetry/skills/02-check/actions/01-locate.md deleted file mode 100644 index 402d10d9c..000000000 --- a/plugins/aidd-telemetry/skills/02-check/actions/01-locate.md +++ /dev/null @@ -1,37 +0,0 @@ -# 01 - Confirm the CLI - -Confirm this machine can answer at all. - -## Output - -A confirmed `aidd` command, or a stated reason it cannot answer. - -## Process - -1. **Require the CLI.** Answering every claim below lives in the `aidd` command, not in a - script beside this skill. - - ```bash - aidd --version - ``` - - No output, or a command that is not found, means this machine cannot answer. **Stop, and - say two things:** - - the `aidd` command is required to answer, and can be installed with - `npm install -g @ai-driven-dev/cli`; - - **recording is unaffected** - the hooks that write the journal are plain node and need - nothing installed, so no measurement is being lost while the CLI is missing. - - Never continue to a report. A missing command is not a measurement of zero, the same way - a tool that writes no token count is not a tool that cost nothing. - -2. **Hand off.** `aidd telemetry check` decides on its own whether measurement is on; - nothing here needs to check the switch first. - -## Test - -| Case | Pass | -| --- | --- | -| `aidd` answers | the run continues to diagnose | -| `aidd` is absent | the run stops, names the CLI, and states that recording is unaffected | -| `aidd` is absent | nothing is run and nothing is reported | diff --git a/plugins/aidd-telemetry/skills/02-check/actions/02-diagnose.md b/plugins/aidd-telemetry/skills/02-check/actions/02-diagnose.md deleted file mode 100644 index 376e6c11a..000000000 --- a/plugins/aidd-telemetry/skills/02-check/actions/02-diagnose.md +++ /dev/null @@ -1,110 +0,0 @@ -# 02 - Run it, and present every line it printed - -Ask `aidd` whether the chain is recording, end to end, and hand back exactly what it found. - -## Input - -A confirmed `aidd` command, from locate. - -## Output - -Every line `aidd telemetry check` printed, unchanged: the stated half first — what is in -place — then either the four claims, or the one line it prints when it stops before -judging anything. - -## Process - -1. **Run it.** - - ```bash - aidd telemetry check - ``` - - It takes no arguments and reads the current project. -2. **Relay what is in place first, always.** Before any verdict it states whether - measurement is allowed and from which file, whose choice that was, whether an identity - is attached, where records would land, whether the recorder is declared, whether each - installed plugin is registered with its host, whether commits carry the session that made - them, and which builds produced what is being read — never a count or a figure other than - the trailer's own. This is not a claim and carries no `ok`/`FAIL`/`--`; - present it even when what follows stops immediately. - - **`plugins registered` is per plugin, and its lines are already ordered.** What will - not load comes first. Relay them in the order printed and never roll them into one - sentence: "some plugins are not registered" is not something a person can act on. - `not-registered` means the host was asked and does not carry it — it will drop the - declaration as orphaned. `registered-disabled` means the host carries it and declines - it. `unanswerable` means nothing was established, and it is never relayed as - "not registered". -3. **Measurement off stops here.** After the stated half, if the next line says measurement - is off, relay that line and stop — there is nothing to check until it is turned on, and - no failure to report. The output is never only that one line: what is in place still - printed above it. -4. **Not a git repository stops here too.** Relay that line and stop, the same way. The - hook writes into the repository's own tree; outside one it has nowhere to write, which - is a fact about the project, not a hook that failed to fire — never relay it as "hook - fired FAIL". -5. **Present every remaining line, in the order printed.** Four claims, then any tool - nothing here can read. Add nothing that sums them: a line summarising the others is - where a failure hides. -6. **No run file yet is not always a failure — and an existing run file is never read as - "no run file" at all.** Which of four readings applies is decided by reading, never - guessed from the absence itself: - - The recorder is declared (the stated half already named where), and no run file has - appeared anywhere: reads `--`, nothing to evaluate. A declaration is not proof the hook - will fire, and the stated half's own `plugins registered` row is where that is - answered — a plugin the host never registered is dropped as orphaned however well it is - declared. Read that row before treating this one as a mystery. - - The recorder is declared nowhere this build checks, and no run file has appeared - anywhere: reads `FAIL`, naming the recorder itself as declared nowhere. - - The stated half's own `recorder declared` row itself said "could not be read": reads - `--`, since whether the recorder is declared could not be read — a damaged declaring - file is never promoted into a guess that the recorder is missing. - - A run file exists but none carry a readable session_start to anchor them (a torn - write, or a hooks block that registers another event without `SessionStart`): reads - `FAIL` regardless of the recorder's own declaration, since a file this build can see - is never "no run file" about. - - A run file was written under a schema this build does not read: reads `FAIL`, naming - every schema stated, since a journal from another version of the plugin is never a - hook that did not fire. This reading wins ahead of the anchorless one above: a build - that cannot read a journal's schema cannot tell a missing `session_start` from one - shaped differently, so blaming a torn write would assert what it just said it cannot - see. - - Every one of these is distinct from a hook that already ran and stopped: a run file - that exists, carries its own session_start, but is not this session's own reads as this - session leaving no run file, naming how stale the newest one is — a hook that died - since the last one, not a hook that never worked. -7. **Read `--` as nothing to evaluate, never as passing.** It means an earlier claim - already explains why this one has no material to check, and that earlier line is where - the reason lives. -8. **An untrusted hook is not a hook that never fired, declared or not.** Codex trusts a - hook per exact event name: a hook approved under an old event name reads untrusted, the - same as one never approved at all, and this reading wins ahead of the declared/nowhere - split above. Only an **absent** `config.toml` falls back to the declared/nowhere - reading; one that exists but approves nothing reads untrusted, the same as an old event - name — a `config.toml` existing is itself Codex's own answer, not a blank to guess at. -9. **A tool marked not covered is never counted toward health.** Its line carries its own reason, read from the same place the cost skill reads it, and stays separate from the four claims. - -## Test - -| Case | Pass | -| --- | --- | -| Any run | what is in place is relayed first, and is never itself `ok`/`FAIL`/`--` | -| A plugin the host never registered | its `plugins registered` line is relayed as `not-registered`, naming the registry file, and appears before any plugin that is fine | -| A plugin whose host registry could not be read | its line is relayed as `unanswerable`, never as `not-registered` | -| A project with no plugin recorded | the row says so in one sentence, and is never relayed as a failure | -| The AIDD manifest could not be read | the row names the file and the reason, and no plugin line is relayed — never read as "no plugin recorded" | -| Commits are being made and none carry the trailer | the count is relayed as `0 of the last N`, with whatever the row names after it — never "the trailer is off" | -| The repository has no commits yet | the row says there is no history to read, and it is never relayed as zero | -| Measurement is off | the stated half is relayed, then that single line, and the run stops before checking anything | -| Not a git repository | the stated half is relayed, then that single line, and the run stops before checking anything — never read as "hook fired FAIL" | -| A healthy install | all four claims read `ok`, each carrying what it was read from | -| The recorder is declared and no run file has appeared yet | hook fired reads `--`, nothing to evaluate — not `FAIL`, and the reason names that a declaration is not proof | -| The recorder is declared nowhere and no run file has appeared | hook fired reads `FAIL`, naming the recorder as what is missing | -| The recorder declaration itself could not be read | hook fired reads `--`, since whether the recorder is declared could not be read — never `FAIL` off a guess | -| A run file exists but none carry a readable session_start | hook fired reads `FAIL`, whatever the recorder's own declaration says — never "no run file" about a file that exists | -| A run file exists but predates this session | the line reads this session left no run file, distinct from never having fired, whatever the recorder's declaration says | -| Only `session_start` was written | that claim alone reads `FAIL`; the claims after it read `--` or `ok`, never `FAIL` for the same reason | -| An untrusted Codex hook, approved under a different event name | hook fired reads untrusted, ahead of either declared/nowhere reading | -| Codex's `config.toml` is absent entirely | hook fired falls back to the declared/nowhere reading, never a guess at trust | -| A tool is not covered | its line names the tool and its reason, and is not read as a fifth failing claim | diff --git a/scripts/__tests__/a-backlog-link-the-reader-can-read.test.js b/scripts/__tests__/a-backlog-link-the-reader-can-read.test.js deleted file mode 100644 index a533c6f84..000000000 --- a/scripts/__tests__/a-backlog-link-the-reader-can-read.test.js +++ /dev/null @@ -1,129 +0,0 @@ -const assert = require("node:assert/strict"); -const cp = require("node:child_process"); -const fs = require("node:fs"); -const path = require("node:path"); -const { describe, it } = require("node:test"); - -const ROOT = path.resolve(__dirname, "../.."); - -/** - * The `by_backlog` axis rests on one file being readable, so a field spelled differently on - * either side makes every declaration unreadable in silence. - * - * The reader lives in `cli/` and this is a repository script test, so the field list is - * restated rather than imported across that boundary. The third case keeps it honest. - */ -const IDENTIFYING_FIELD = "backlog"; -const REQUIRED_FIELDS = [IDENTIFYING_FIELD, "written_at", "written_by"]; - -function shapeOf(value) { - if (value === null) return "null"; - if (Array.isArray(value)) return "an array"; - return typeof value; -} - -/** The first json fence, matched with the pattern - * `cli/tests/contexts/telemetry/infrastructure/task-backlog-skill-shape.integration.test.ts` - * uses. No gate runs that sibling on `plugins/**`, so two guards reading different fences - * leave this one green and land its red on `next`. */ -function taughtInMarkdown(file, markdown) { - const fences = [...markdown.matchAll(/^[ \t]*```json\r?\n([\s\S]*?)\r?\n[ \t]*```/gmu)].map((m) => m[1]); - assert.ok(fences.length > 0, `${file} must teach the example in a json fence`); - - const taught = taughtAsJson(file, fences[0]); - assert.ok( - IDENTIFYING_FIELD in taught, - `${file}'s first json fence must be the taught example, carrying "${IDENTIFYING_FIELD}". ` + - `Read ${fences.length} fence(s).` - ); - return taught; -} - -function taughtAsJson(file, text) { - let taught; - try { - taught = JSON.parse(text); - } catch (error) { - assert.fail(`${file} teaches an example that is not JSON: ${error.message}`); - } - assert.ok( - taught !== null && typeof taught === "object" && !Array.isArray(taught), - `${file} must teach an object, not ${shapeOf(taught)}` - ); - return taught; -} - -function trackedBacklogLinks() { - return cp - .execSync("git ls-files '*backlog-link.json'", { cwd: ROOT, encoding: "utf8" }) - .trim() - .split(/\r?\n/) - .filter(Boolean); -} - -describe("every backlog declaration in this repository is one the report can read", () => { - it("names the fields the reader looks for, in the spelling it looks for them", () => { - const unreadable = []; - - for (const file of trackedBacklogLinks()) { - let parsed; - try { - parsed = JSON.parse(fs.readFileSync(path.join(ROOT, file), "utf8")); - } catch (error) { - unreadable.push(`${file} is not JSON: ${error.message}`); - continue; - } - const missing = REQUIRED_FIELDS.filter( - (field) => typeof parsed[field] !== "string" || parsed[field] === "" - ); - if (missing.length > 0) { - unreadable.push( - `${file} is missing ${missing.join(", ")} (has ${Object.keys(parsed).join(", ")})` - ); - } - } - - assert.deepEqual( - unreadable, - [], - `A task folder declares a backlog item the report cannot read, so its work counts as ` + - `\`unreadable\` and the item is never named.\n${unreadable.join("\n")}` - ); - }); - - /** A taught shape drifting away from the reader is what produced the two unreadable files, - * and a guard checking only its own field list would have stayed green through it. The - * example is parsed, not searched: one that keeps the three names while ceasing to be JSON - * is unreadable all the same. */ - it("is the shape both skills that write it actually teach", () => { - const TEACHING_FILES = [ - "plugins/aidd-pm/skills/04-spec/assets/backlog-link-template.json", - "plugins/aidd-dev/skills/01-plan/actions/04-plan.md", - ]; - - for (const file of TEACHING_FILES) { - const text = fs.readFileSync(path.join(ROOT, file), "utf8"); - const taught = file.endsWith(".md") ? taughtInMarkdown(file, text) : taughtAsJson(file, text); - for (const field of REQUIRED_FIELDS) { - assert.ok( - typeof taught[field] === "string" && taught[field] !== "", - `${file} must teach the field "${field}" as a non-empty string` - ); - } - } - }); - - /** The check above pins the writer's side alone, so this reads the field access of - * `TaskBacklogAdapter.read` across the boundary the header explains. */ - it("is the shape the reader in cli/ actually reads, not just the shape skills teach", () => { - const READER_FILE = "cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts"; - const text = fs.readFileSync(path.join(ROOT, READER_FILE), "utf8"); - - for (const field of REQUIRED_FIELDS) { - assert.ok( - text.includes(`parsed.${field}`), - `${READER_FILE} must read \`parsed.${field}\`, the same field the skills teach` - ); - } - }); -}); diff --git a/scripts/__tests__/aidd-orchestrator-no-host-reports.test.js b/scripts/__tests__/aidd-orchestrator-no-host-reports.test.js deleted file mode 100644 index 9be03b2c2..000000000 --- a/scripts/__tests__/aidd-orchestrator-no-host-reports.test.js +++ /dev/null @@ -1,42 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const test = require("node:test"); - -const ROOT = path.resolve(__dirname, "../.."); - -/** - * A skill never links outside itself, so the explanation of why a skill must announce its own - * end is written out in full in every orchestrating skill rather than linked once. This pins - * the copies to each other: one could otherwise drift and a flow would stop being measured - * with nothing going red. - */ -const ORCHESTRATING_SKILLS = [ - "plugins/aidd-orchestrator/skills/00-async-dev/SKILL.md", - "plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md", - "plugins/aidd-orchestrator/skills/02-backlog/SKILL.md", -]; - -const PARAGRAPH_START = "No host reports when an orchestration finished."; - -function sharedExplanation(file) { - // A Windows checkout may carry CRLF; the comparison is about words, never line endings. - const text = fs.readFileSync(path.join(ROOT, file), "utf8").replace(/\r\n/g, "\n"); - const start = text.indexOf(PARAGRAPH_START); - assert.ok(start !== -1, `${file} no longer carries the shared "No host reports" explanation`); - const end = text.indexOf("\n\n", start); - return text.slice(start, end === -1 ? undefined : end).trim(); -} - -test('every orchestrating skill\'s "No host reports" explanation agrees with the others, word for word', () => { - const [canonicalFile, ...rest] = ORCHESTRATING_SKILLS; - const canonical = sharedExplanation(canonicalFile); - - for (const file of rest) { - assert.equal( - sharedExplanation(file), - canonical, - `${file}'s explanation drifted from ${canonicalFile}'s - one of the two now says something different` - ); - } -}); diff --git a/scripts/__tests__/aidd-telemetry-cost-skill.test.js b/scripts/__tests__/aidd-telemetry-cost-skill.test.js deleted file mode 100644 index 914f716d9..000000000 --- a/scripts/__tests__/aidd-telemetry-cost-skill.test.js +++ /dev/null @@ -1,384 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -const pluginDir = path.resolve(__dirname, "../../plugins/aidd-telemetry"); -const skillDir = path.join(pluginDir, "skills/01-cost"); -// Normalized, because these tests match multi-line shapes against the file's own text and -// git hands a Windows checkout the same content with CRLF endings, where `\n\n` matches -// nothing - which is how a green suite failed on Windows alone. -const read = (file) => fs.readFileSync(file, "utf8").replace(/\r\n/gu, "\n"); -const skill = read(path.join(skillDir, "SKILL.md")); -// A router skill's rules live in its actions; reading only the router would test a -// table of contents. -const actions = fs - .readdirSync(path.join(skillDir, "actions")) - .map((name) => read(path.join(skillDir, "actions", name))) - .join("\n"); -const everything = `${skill}\n${actions}`; - -// The whole point of the machine-readable output is that a skill consumes it. A skill -// reading the human rendering scrapes aligned columns and breaks when one gets wider. -test("the cost skill reads the object, never the text meant for a person", () => { - assert.ok(everything.includes("--json"), "must ask for the machine-readable output"); - assert.ok( - everything.includes("cost_report_version"), - "must refuse a version it does not know, which means naming the field" - ); -}); - -test("the cost skill branches on declared capability, not on a missing number", () => { - for (const field of ["capability", "journal_attributable", "task_attributable"]) { - assert.ok(everything.includes(field), `must read "${field}" rather than infer the limit`); - } -}); - -test("the cost skill says when a total is partial", () => { - assert.ok(everything.includes("undated_records"), "must notice records in no period"); - assert.ok(everything.includes("unreadable_lines"), "must notice lines it could not read"); -}); - -test("the cost skill prefers an absolute period for a figure that will be kept", () => { - assert.ok(everything.includes("--from"), "must know the absolute flags"); - assert.ok(everything.includes("resolves against today"), "must say why --days cannot be cited"); -}); - -// Closure tests, never a wordlist of forbidden phrases: a blacklist is a guess about how a -// mistake will be spelled, and rewording walks straight past it. The only rendering an agent -// could scrape and add up by hand is the column-aligned one built for a person, so every -// command the skill instructs is checked against the binary's own interface and every -// `report` call must end on a path that hands back a value already computed. -// -// A sentence contradicting a correct instruction elsewhere in the file is not covered here: -// nothing about the text proves which of the two an agent follows. - -/** Every `` `aidd telemetry report ` `` the skill's actions write, flags only: the - * subcommand is fixed in the pattern and not captured, so a bare mention of the command in - * prose never matches, since that requires a space and at least one more character. */ -function reportCommands(text) { - return [...text.matchAll(/`aidd telemetry report ([^`]+)`/gu)].map((m) => m[1].trim()); -} - -test("every report invocation asks for the object or a derived artefact, never the bare human table", () => { - const commands = reportCommands(everything); - // A closure test over an empty extraction passes vacuously, which is what happened here - // while the pattern still named the deleted `telemetry-report.cjs`. - assert.ok(commands.length > 0, "the extraction must not be vacuous"); - for (const flags of commands) { - const tokens = flags.split(/\s+/u); - assert.ok( - tokens.includes("--json") || tokens.includes("--axis"), - `"report ${flags}" names neither --json nor --axis, so it would print the human table` - ); - } -}); - -/** The envelope the CLI is pinned against, read from the committed fixture rather than built - * here, so the builder lives in one place. Every capability is populated, so a path the - * fixture does not exercise is never counted as unreachable by accident. */ -function realEnvelope() { - return JSON.parse( - fs.readFileSync( - path.resolve(__dirname, "../../cli/tests/fixtures/cli-owns-read/expected-envelope.json"), - "utf8" - ) - ); -} - -/** Flattened by key alone, arrays made transparent: a doc saying "local_read.amount" means - * `by_tool[].capability.local_read.amount`, and the array index in between is never a name - * worth requiring a doc to spell out. */ -function envelopePaths(value, prefix, into) { - if (Array.isArray(value)) { - for (const item of value) envelopePaths(item, prefix, into); - return into; - } - if (value !== null && typeof value === "object") { - for (const [key, child] of Object.entries(value)) { - const nextPath = prefix ? `${prefix}.${key}` : key; - into.add(nextPath); - envelopePaths(child, nextPath, into); - } - } - return into; -} - -/** Backticked, lower-case, and carrying an underscore somewhere - the shape every real - * field name in this envelope has (`cost_report_version`, `by_step`, `local_read.amount`), - * and an ordinary English word in backticks (`capability`, `axis`) does not. Keeps this a - * claim about a field rather than a sweep of every code-styled word in the file. */ -function fieldClaims(text) { - const claims = new Set(); - for (const m of text.matchAll(/`([a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*)`/gu)) { - if (m[1].includes("_")) claims.add(m[1]); - } - return claims; -} - -test("every field the cost skill names by name resolves on the object the script actually emits", () => { - const paths = envelopePaths(realEnvelope(), "", new Set()); - const claims = fieldClaims(everything); - // Pinned for the reason the command count above is: an extractor matching nothing would - // make this pass on an empty set of claims. The number moves whenever the skill names a - // field it did not before, which is a decision, not a drift. - assert.equal(claims.size, 19, "expected exactly nineteen field references in the cost skill"); - - // Fields the envelope carries only under some condition, so a fixture cannot show them all - // at once: the first five appear only under a selection, and `cost_micro_usd` only once a - // record states an amount — which no tool read locally does today, which is why every - // figure reads "amount unknown". Their presence and shape are pinned by the CLI's own - // envelope tests; what is pinned here is that the skill names no field that exists nowhere. - const CONDITIONAL = new Set([ - "task", - "task_attribution", - "filters", - "empty_selection", - "active_time_s", - "totals.cost_micro_usd", - "cost_micro_usd", - ]); - const resolves = (id) => - CONDITIONAL.has(id) || paths.has(id) || [...paths].some((p) => p.endsWith(`.${id}`)); - for (const claim of claims) { - assert.ok(resolves(claim), `"${claim}" does not name a field anywhere on the envelope`); - } -}); - -test("the cost skill refuses to invent a figure when its script is absent", () => { - assert.ok(everything.includes("show no figure"), "must state that no figure is shown"); - assert.ok( - everything.includes("cannot be found"), - "must name the unresolvable script as the reason" - ); -}); - -test("the cost skill never turns unattributed into a claim that no step ran", () => { - assert.ok(everything.includes("unattributed"), "must name the value the report prints"); - assert.ok(!everything.includes("no step ran\n"), "must not restate it as a claim"); -}); - -test("the cost skill states the limits a reader will ask about", () => { - // Named where the skill acts on them, not only linked: a limit a reader has to go and - // look up is a limit that gets read as a zero. - for (const limit of ["not covered", "unattributed", "unknown"]) { - assert.ok(everything.includes(limit), `must handle "${limit}" rather than defer it`); - } -}); - -test("the plugin README gives every partly-measurable tool its reason, not just its name", () => { - // Pinned on the reason rather than on a heading: the headings have already had to change - // once, when a tool that "cannot be measured at all" turned out to journal fine. - const readme = fs.readFileSync(path.join(pluginDir, "README.md"), "utf8"); - for (const [tool, reason] of [ - ["Cursor", "no token count in any file"], - ["Copilot", "session total only, no per-request figure"], - ["Codex", "trust"], - ]) { - assert.ok(readme.includes(tool), `${tool} is named`); - assert.ok(readme.includes(reason), `${tool}'s reason, not just its name`); - } - // V1 can omit an announcement; V2 supplies the session directory. Keep V1's fallback - // and its multi-project cost explicit instead of presenting both versions as equivalent. - const readmeText = readme.replace(/\s+/gu, " "); - assert.ok( - readmeText.includes("OpenCode V1 can omit the session announcement"), - "OpenCode's missing announcement is explicitly scoped to V1" - ); - assert.ok( - readmeText.includes( - "Without a known session directory, it uses the plugin's startup directory, which can be wrong for a server serving several projects." - ), - "the startup-directory fallback and its multi-project cost remain explicit" - ); -}); - -test("no skill searches plugin directories for its own script any more", () => { - // None of the three skills ships a script to find any more - each calls `aidd` instead - - // so this pins that no locate or action file resurrects the plugin-root search. - for (const skill of ["00-init", "01-cost", "02-check"]) { - const dir = path.join(pluginDir, "skills", skill, "actions"); - const text = fs - .readdirSync(dir) - .map((name) => fs.readFileSync(path.join(dir, name), "utf8")) - .join("\n"); - assert.ok(!text.includes("find "), `${skill} must not search plugin directories for a script`); - } -}); - -test("the init skill owns turning measurement on, and asks first", () => { - const initDir = path.join(pluginDir, "skills/00-init"); - const init = fs - .readdirSync(path.join(initDir, "actions")) - .map((name) => fs.readFileSync(path.join(initDir, "actions", name), "utf8")) - .join("\n"); - - // `aidd telemetry on` is the place that turns it on, and the skill names no `.cjs` path. - assert.ok(init.includes("aidd telemetry on"), "must be the place that turns it on"); - assert.ok(/[Aa]sk/u.test(init), "must ask before measuring someone's project"); - assert.ok(!/\.cjs\b/u.test(init), "must not name a script beside itself any more"); -}); - -test("the cost skill defers enabling to init rather than doing it itself", () => { - assert.ok( - !/telemetry-switch/u.test(everything), - "reporting must not turn measurement on behind the user's back" - ); -}); - -// The coupling this split exists to remove: a skill that reads a file belonging to another -// skill breaks the day a host installs one of them and not the other. -test("no skill reaches into another skill's directory", () => { - const skills = ["00-init", "01-cost", "02-check"]; - const pairs = skills.flatMap((own) => - skills.filter((other) => other !== own).map((other) => [own, other]) - ); - for (const [own, other] of pairs) { - const dir = path.join(pluginDir, "skills", own); - const text = fs - .readdirSync(path.join(dir, "actions")) - .map((name) => fs.readFileSync(path.join(dir, "actions", name), "utf8")) - .concat(fs.readFileSync(path.join(dir, "SKILL.md"), "utf8")) - .join("\n"); - - assert.ok(!text.includes(other), `${own} must not name ${other}'s directory`); - } -}); - -test("the check skill calls the CLI, never a script of its own", () => { - // `aidd telemetry check` judges every claim, and the skill names no `.cjs` path. - const checkDir = path.join(pluginDir, "skills/02-check"); - const check = fs - .readdirSync(path.join(checkDir, "actions")) - .map((name) => fs.readFileSync(path.join(checkDir, "actions", name), "utf8")) - .concat(fs.readFileSync(path.join(checkDir, "SKILL.md"), "utf8")) - .join("\n"); - - assert.ok(check.includes("aidd telemetry check"), "must call the CLI's check command"); - assert.ok(!/\.cjs\b/u.test(check), "must not name a script beside itself any more"); -}); - -// The skill names the version it refuses to read past. Nothing checked that number against -// the one the CLI emits, so a bump could ship with the skill still naming the version -// before it - a skill that then refuses the object it was built to read. Same guard the -// contract document already has (`cost-report-contract.unit.test.ts`), for the second place -// the number is written down. -test("the cost skill names the envelope version the CLI actually emits", () => { - const envelopeSource = fs.readFileSync( - path.resolve(__dirname, "../../cli/src/contexts/telemetry/domain/cost-report-envelope.ts"), - "utf8" - ); - const emitted = /COST_REPORT_ENVELOPE_VERSION = (\d+)/u.exec(envelopeSource)?.[1]; - const named = /`cost_report_version` is `(\d+)` today/u.exec(everything)?.[1]; - - assert.equal(named, emitted, "the skill's stated version must be the one the CLI emits"); -}); - -// A skill told to "report what it printed" leaves the shape to the model, and two runs -// answer differently. The shape is stated so a user reads the same table every time. -test("the cost skill states the shape of its answer", () => { - const report = fs.readFileSync(path.join(skillDir, "actions/03-report.md"), "utf8"); - - assert.ok(report.includes("| Step | Share | Tokens | Attribution |"), "a step table"); - assert.ok(report.includes("| Model | Share | Tokens |"), "a model table"); - assert.ok(report.includes("| Sessions |"), "a headline table"); - assert.ok( - report.includes("never a table of zeroes"), - "must say an empty breakdown is left out rather than filled with zeroes" - ); -}); - -// Someone asking what last month cost does not know which axis answers them - the skill -// has to derive the axis from the question, not hand back a flag for the person to pick. -test("the cost skill offers its axes in the language of a question", () => { - // Pin the `--axis <...>` enumeration itself, not a loose substring: "person" is a - // substring of prose like "per person" too, so a bare `everything.includes(axis)` would - // still pass on docs that never added the axis at all. - const axisFlagEnum = /--axis <([^>]+)>/u.exec(everything)?.[1].split("|") ?? []; - for (const axis of ["total", "day", "step", "model", "tool", "project", "person"]) { - assert.ok(axisFlagEnum.includes(axis), `must list "${axis}" among the --axis choices`); - } - for (const question of ["what did this cost", "what changed", "where did it go"]) { - assert.ok(everything.includes(question), `must speak in the language of "${question}"`); - } - assert.ok(everything.includes("--axis"), "must derive the flag itself, from the question"); -}); - -// The skill's own axis list drifted twice while the CLI grew one: `agent` shipped and was -// named in neither the `--axis` enumeration nor the question table, so a person asking "which -// subagent spent this" was told the question had no axis while the binary had answered it for -// two releases. Read the axes the binary actually accepts, rather than restating them here, -// so the next one cannot ship unoffered. -test("the cost skill offers every axis the binary accepts, in both places it names them", () => { - const artefactSource = read( - path.resolve(__dirname, "../../cli/src/presentation/display/cost-report-artefact.ts") - ); - const declared = /export const ARTEFACT_AXES = \[([^\]]+)\]/u.exec(artefactSource)?.[1] ?? ""; - const axes = [...declared.matchAll(/"([a-z]+)"/gu)].map((match) => match[1]); - assert.ok(axes.length > 1, "must have read the axis list from the artefact module itself"); - - const axisFlagEnum = /--axis <([^>]+)>/u.exec(everything)?.[1].split("|") ?? []; - const questionTable = - /\| The question sounds like \| Axis \| Artefact \|[\s\S]*?\n\n/u.exec(skill)?.[0] ?? ""; - // Both directions: an axis the binary accepts must be offered, and one the skill offers - // must exist - the second is not covered elsewhere, since the e2e that runs every command - // the skill names expands this enumeration to its first alternative alone. - assert.deepEqual(axisFlagEnum, axes, "the --axis choices must be exactly the axes that exist"); - for (const axis of axes) { - assert.ok(axisFlagEnum.includes(axis), `must list "${axis}" among the --axis choices`); - assert.ok( - new RegExp(`\\|[^|\\n]*\\b${axis}\\b[^|\\n]*\\|`, "u").test(questionTable), - `must map a question to the "${axis}" axis in SKILL.md's own table` - ); - } -}); - -// The reader itself, against a file on disk with the endings git hands a Windows checkout - -// not a string normalized inside the assertion, which would pass however the reader behaves. -test("reads a Windows checkout the same way it reads a POSIX one", () => { - const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-crlf-")); - const file = path.join(scratch, "SKILL.md"); - fs.writeFileSync(file, skill.replace(/\n/gu, "\r\n")); - try { - const asRead = read(file); - - assert.equal(asRead, skill, "must hand back the same text a POSIX checkout would"); - assert.ok( - /\| The question sounds like \| Axis \| Artefact \|[\s\S]*?\n\n/u.test(asRead), - "the axis table must still be found in a file checked out with CRLF endings" - ); - } finally { - fs.rmSync(scratch, { recursive: true, force: true }); - } -}); - -// A record now carries an identity resolved across tools and machines, so the skill must -// offer the `person` axis rather than still calling the question structurally unanswerable. -test("the cost skill answers per person through the person axis, not as unanswerable", () => { - assert.ok(/per.person/iu.test(everything), "must still speak in the language of the question"); - assert.ok(everything.includes("|person>"), "must list person among the --axis choices"); - assert.ok( - // Pin the whole row, axis column included: the question phrase alone also sat in the - // old "none - unanswerable" row, so a substring match on the phrase alone would still - // pass on fully reverted docs. - everything.includes("| per person, who spent, which teammate | person |"), - "must map the per-person question to the person axis in SKILL.md's own table" - ); - assert.ok( - !/unanswerable/iu.test(everything), - "must not still claim per-person cannot be answered" - ); -}); - -// A total to quote and a table to paste are different things - a rendering suited to the -// axis, written to a file when a file is what was asked for. -test("the cost skill writes an artefact to a file when a file is what was asked for", () => { - assert.ok(everything.includes("Write it to a file"), "must say when it writes rather than shows"); - assert.ok(everything.includes("Show it inline"), "must say when it shows rather than writes"); - assert.ok( - /states its period and (its )?axis/u.test(everything) || - everything.includes("period and its axis"), - "an artefact must name the period and axis it came from" - ); -}); diff --git a/scripts/__tests__/aidd-telemetry-file-writes.test.js b/scripts/__tests__/aidd-telemetry-file-writes.test.js deleted file mode 100644 index a546bdeda..000000000 --- a/scripts/__tests__/aidd-telemetry-file-writes.test.js +++ /dev/null @@ -1,232 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); -const { spawnSync } = require("node:child_process"); - -const root = path.resolve(__dirname, "../.."); -const FILE_WRITES = path.join(root, "plugins/aidd-telemetry/hooks/lib/file-writes.cjs"); -const RECORD = path.join(root, "plugins/aidd-telemetry/hooks/lib/record.cjs"); - -// Under a git hook, git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE, which would -// point every child git call here at the real repository instead of the temporary one. -const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")), -); - -function makeTempDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function writeTelemetryConfig(repo) { - fs.mkdirSync(path.join(repo, ".aidd"), { recursive: true }); - fs.writeFileSync( - path.join(repo, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true, endpoint: "http://127.0.0.1:4318" } }), - ); -} - -// One folder per task holding a handful of files each - the shape a real task tree grows -// in, and the shape that exhausts the entry budget on folder names before it reaches files -// at all if a single directory is wide enough. -function buildWideTaskTree(tasksDir, totalFiles, filesPerFolder) { - let written = 0; - let index = 0; - while (written < totalFiles) { - const folder = path.join(tasksDir, `2026_08_01_wide-task-${index++}`); - fs.mkdirSync(folder, { recursive: true }); - for (let i = 0; i < filesPerFolder && written < totalFiles; i++) { - fs.writeFileSync(path.join(folder, `note-${i}.md`), "x"); - written++; - } - } -} - -test("reports itself complete, having examined every entry, when the tree fits inside the budget", () => { - const repo = makeTempDir("aidd-walk-small-"); - buildWideTaskTree(path.join(repo, "aidd_docs", "tasks", "2026_08"), 40, 8); - - const { taskFilesModifiedSince } = require(FILE_WRITES); - const result = taskFilesModifiedSince(repo, 0); - - assert.equal(result.truncated, false); - assert.equal(result.found.length, 40); - fs.rmSync(repo, { recursive: true, force: true }); -}); - -test("reports itself truncated, and exactly how many entries it examined, when a directory is wider than the budget", () => { - const repo = makeTempDir("aidd-walk-wide-"); - const { taskFilesModifiedSince, MAX_SCAN_ENTRIES } = require(FILE_WRITES); - const totalFiles = MAX_SCAN_ENTRIES + 300; - buildWideTaskTree(path.join(repo, "aidd_docs", "tasks", "2026_08"), totalFiles, 8); - - const result = taskFilesModifiedSince(repo, 0); - - assert.equal(result.truncated, true); - assert.equal(result.scanned, MAX_SCAN_ENTRIES, "the entry that would cross the cap must not be counted"); - assert.ok(result.found.length < totalFiles, "found every file despite the cap"); - fs.rmSync(repo, { recursive: true, force: true }); -}); - -// The discriminating case: a single wide directory, no per-task subfolders (a task written -// as one .md file, which taskOf() and TASK_SEGMENT_PATTERN both treat as a real task shape). -// A `pending`-only truncation check goes false here even though the budget ran out mid-way -// through this one directory's own listing: `pending` empties as soon as this directory is -// popped, before its entries are ever read, so a cut-short listing looks identical to a -// finished one unless the cut itself is tracked. -test("reports itself truncated even when the cap is hit inside one directory's own listing, with nothing left queued", () => { - const repo = makeTempDir("aidd-walk-flat-"); - const { taskFilesModifiedSince, MAX_SCAN_ENTRIES } = require(FILE_WRITES); - const tasksDir = path.join(repo, "aidd_docs", "tasks", "2026_08"); - fs.mkdirSync(tasksDir, { recursive: true }); - const totalFiles = MAX_SCAN_ENTRIES + 300; - for (let i = 0; i < totalFiles; i++) { - fs.writeFileSync(path.join(tasksDir, `2026_08_01_flat-task-${i}.md`), "x"); - } - - const result = taskFilesModifiedSince(repo, 0); - - assert.equal(result.truncated, true, "a listing cut off mid-read must not read as complete"); - assert.equal(result.scanned, MAX_SCAN_ENTRIES); - assert.ok(result.found.length < totalFiles); - fs.rmSync(repo, { recursive: true, force: true }); -}); - -test("tells the run file what it skipped, rather than reading as complete coverage, when the walk hits its cap", () => { - const repo = makeTempDir("aidd-walk-cap-"); - spawnSync("git", ["init", "-q", repo], { encoding: "utf8", env: CLEAN_ENV }); - writeTelemetryConfig(repo); - - const { buildSessionStartLine, appendLine, runFileName, generateUlid } = require(RECORD); - const { handleTaskFilesObserved, MAX_SCAN_ENTRIES } = require(FILE_WRITES); - - const runsDir = path.join(repo, "aidd_docs", "runs"); - fs.mkdirSync(runsDir, { recursive: true }); - const runId = generateUlid(); - const vendorId = "wide-tree-session"; - const runFile = path.join(runsDir, runFileName(runId, vendorId)); - appendLine( - runFile, - buildSessionStartLine({ - at: "2026-08-01T00:00:00Z", - runId, - projectId: "acme/repo", - projectRemote: null, - host: "claude-code", - vendorId, - }), - ); - - buildWideTaskTree(path.join(repo, "aidd_docs", "tasks", "2026_08"), MAX_SCAN_ENTRIES + 300, 8); - - handleTaskFilesObserved({ cwd: repo }, "claude-code", vendorId); - - const lines = fs - .readFileSync(runFile, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const truncation = lines.find((line) => line.type === "scan_truncated"); - - assert.ok(truncation, "no line recorded that the walk gave up before finishing"); - assert.equal(truncation.cap, MAX_SCAN_ENTRIES); - assert.equal(truncation.scanned, MAX_SCAN_ENTRIES); - - const fileWrittenCount = lines.filter((line) => line.type === "file_written").length; - assert.ok(fileWrittenCount < MAX_SCAN_ENTRIES + 300, "found every file despite the cap"); - fs.rmSync(repo, { recursive: true, force: true }); -}); - -// No host declared today can witness a repository resolved from payload.cwd instead of the -// host: the four naming no written path return before that line, and the one that names a -// path also carries cwd. A host is a table entry, so the witness is one more entry. -const TOOLS_INDEX = path.join(root, "plugins/aidd-telemetry/hooks/lib/tools/index.cjs"); - -const WORKSPACE_HOST = "probe-workspace-host"; - -const workspaceHostTool = { - readSessionId: (payload) => payload.conversationId, - readCwd: (payload) => payload.workspacePaths[0], - vendorField: null, - stepStart: { skillName: () => null, turnIdField: null }, - writtenPath: (payload) => payload.toolCall.target, -}; - -// file-writes.cjs and record.cjs destructure the table at require time, so the entry goes in -// before they are loaded and both are dropped again on the way out. -function withWorkspaceHost(run) { - const indexId = require.resolve(TOOLS_INDEX); - const realExports = require(TOOLS_INDEX); - const tools = Object.freeze({ ...realExports.TOOLS_BY_HOST, [WORKSPACE_HOST]: workspaceHostTool }); - const toolFor = (host) => tools[host] || null; - require.cache[indexId].exports = { - ...realExports, - TOOLS_BY_HOST: tools, - toolFor, - readCwd: (host, payload) => (toolFor(host) ? toolFor(host).readCwd(payload) : undefined), - readSessionId: (host, payload) => (toolFor(host) ? toolFor(host).readSessionId(payload) : undefined), - }; - delete require.cache[require.resolve(FILE_WRITES)]; - delete require.cache[require.resolve(RECORD)]; - try { - return run({ fileWrites: require(FILE_WRITES), record: require(RECORD) }); - } finally { - require.cache[indexId].exports = realExports; - delete require.cache[require.resolve(FILE_WRITES)]; - delete require.cache[require.resolve(RECORD)]; - } -} - -test("records the write a host stated when that host names its workspace rather than a cwd", () => { - // realpath: the stated path is resolved through realpathSync, and the temporary directory - // is a symlink on macOS, so an unresolved root would never prefix it. - const repo = fs.realpathSync.native(makeTempDir("aidd-stated-workspace-")); - spawnSync("git", ["init", "-q", repo], { encoding: "utf8", env: CLEAN_ENV }); - writeTelemetryConfig(repo); - - const taskFolder = path.join(repo, "aidd_docs", "tasks", "2026_08", "2026_08_01_workspace-task"); - fs.mkdirSync(taskFolder, { recursive: true }); - const written = path.join(taskFolder, "plan.md"); - fs.writeFileSync(written, "x"); - - const vendorId = "workspace-host-session"; - const runFile = withWorkspaceHost(({ fileWrites, record }) => { - const runsDir = path.join(repo, "aidd_docs", "runs"); - fs.mkdirSync(runsDir, { recursive: true }); - const runId = record.generateUlid(); - const filePath = path.join(runsDir, record.runFileName(runId, vendorId)); - record.appendLine( - filePath, - record.buildSessionStartLine({ - at: "2026-08-01T00:00:00Z", - runId, - projectId: "acme/repo", - projectRemote: null, - host: WORKSPACE_HOST, - vendorId, - }), - ); - - // The hook's own working directory is outside the repository, as a machine-wide hook - // declaration leaves it. - fileWrites.handleFileWritten( - { workspacePaths: [repo], toolCall: { target: written } }, - WORKSPACE_HOST, - vendorId, - ); - return filePath; - }); - - const lines = fs - .readFileSync(runFile, "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - const stated = lines.filter((line) => line.type === "file_written"); - - assert.equal(stated.length, 1, "the path the host stated was dropped"); - assert.equal(stated[0].source, "tool-stated", "and it must say the host stated it, not that a walk found it"); - assert.equal(stated[0].path, "aidd_docs/tasks/2026_08/2026_08_01_workspace-task/plan.md"); - fs.rmSync(repo, { recursive: true, force: true }); -}); diff --git a/scripts/__tests__/aidd-telemetry-journal-perf-harness.js b/scripts/__tests__/aidd-telemetry-journal-perf-harness.js deleted file mode 100644 index 3eccd83ab..000000000 --- a/scripts/__tests__/aidd-telemetry-journal-perf-harness.js +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -// Measures journal.cjs's in-process turn-end and file-written latency, run as a child of the -// p95 tests. Not a *.test.js file, so node --test does not pick it up, and a separate process -// so the parent can enforce a wall-clock timeout with a real kill: node:test's own timeout -// does not interrupt a blocking synchronous call. - -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { execFileSync } = require("node:child_process"); - -const { processPayload, generateUlid } = require("../../plugins/aidd-telemetry/hooks/journal.cjs"); - -// Under a git hook, git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE, which -// would point every child git call here at the real repository instead of the -// temporary one. Strip them, or "git remote add origin" edits the repo running -// the test. -const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")), -); - - -function makeTempDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -const repo = makeTempDir("aidd-telemetry-perf-repo-"); -execFileSync("git", ["init", "-q"], { cwd: repo, env: CLEAN_ENV }); -execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, env: CLEAN_ENV }); -execFileSync("git", ["config", "user.name", "Test"], { cwd: repo, env: CLEAN_ENV }); -execFileSync("git", ["remote", "add", "origin", "git@github.com:acme/perf.git"], { cwd: repo, env: CLEAN_ENV }); - -const dir = path.join(repo, "aidd_docs", "runs"); -fs.mkdirSync(dir, { recursive: true }); - -fs.mkdirSync(path.join(repo, ".aidd"), { recursive: true }); -fs.writeFileSync( - path.join(repo, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true, endpoint: "http://127.0.0.1:4318" } }), -); - -const sessionId = "perf-target-session"; -function payload(event) { - return { - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: event, - source: "startup", - }; -} - -function fileWrittenPayload(toolName, filePath) { - return { - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: "PostToolUse", - tool_name: toolName, - tool_input: toolName === "Bash" ? { command: "echo hi" } : { file_path: filePath }, - }; -} - -processPayload(payload("SessionStart")); - -// Several hundred *other* sessions' files already in the directory, with -// deliberately unparseable content: the lookup must never read it. -const SEED_COUNT = 300; -for (let i = 0; i < SEED_COUNT; i++) { - const runId = generateUlid(); - fs.writeFileSync(path.join(dir, `${runId}__seed-session-${i}.jsonl`), "not real json, never read {{{"); -} - -function measure(label, count, fn) { - const durationsMs = []; - for (let i = 0; i < count; i++) { - const startedAt = process.hrtime.bigint(); - fn(i); - const elapsedNs = process.hrtime.bigint() - startedAt; - durationsMs.push(Number(elapsedNs) / 1e6); - } - durationsMs.sort((a, b) => a - b); - const p95Index = Math.ceil(durationsMs.length * 0.95) - 1; - return { - label, - n: durationsMs.length, - p95: durationsMs[p95Index], - max: durationsMs[durationsMs.length - 1], - mean: durationsMs.reduce((a, b) => a + b, 0) / durationsMs.length, - }; -} - -const INVOCATIONS = 100; - -const turnEnd = measure("turn-end", INVOCATIONS, () => processPayload(payload("Stop"))); - -// The reject path: a Bash call, never even reaching a git shellout - the -// case that matters most, since file-written fires on every tool call. -const fileWrittenReject = measure("file-written-reject", INVOCATIONS, () => - processPayload(fileWrittenPayload("Bash")), -); - -// The accept path: a real Write into a real task folder, one per -// invocation so each hits the disk for real rather than measuring a cache. -const taskDir = path.join(repo, "aidd_docs", "tasks", "2026_08", "2026_08_18_perf-task"); -fs.mkdirSync(taskDir, { recursive: true }); -let acceptCounter = 0; -const fileWrittenAccept = measure("file-written-accept", INVOCATIONS, () => { - const filePath = path.join(taskDir, `note-${acceptCounter++}.md`); - fs.writeFileSync(filePath, "x"); - processPayload(fileWrittenPayload("Write", filePath)); -}); - -const result = { - seeded: SEED_COUNT, - n: turnEnd.n, - p95: turnEnd.p95, - mean: turnEnd.mean, - max: turnEnd.max, - fileWrittenReject, - fileWrittenAccept, -}; - -fs.rmSync(repo, { recursive: true, force: true }); - -process.stdout.write(JSON.stringify(result)); diff --git a/scripts/__tests__/aidd-telemetry-journal.test.js b/scripts/__tests__/aidd-telemetry-journal.test.js deleted file mode 100644 index 67b6cbfc7..000000000 --- a/scripts/__tests__/aidd-telemetry-journal.test.js +++ /dev/null @@ -1,3704 +0,0 @@ -const assert = require("node:assert/strict"); -const childProcess = require("node:child_process"); - -// Patched before repo.cjs is required, which destructures spawnSync at require time. This -// is what lets countGitInvocations() count real git calls in-process, with no PATH shim: a -// shim needs a shebang and the execute bit, neither of which Windows honours. -let gitCallCounter = null; -const realSpawnSync = childProcess.spawnSync; -childProcess.spawnSync = (command, ...rest) => { - if (gitCallCounter && command === "git") gitCallCounter.count += 1; - return realSpawnSync(command, ...rest); -}; - -const { execFileSync, spawnSync, spawn } = childProcess; -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -// Under a git hook, git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE, which -// would point every child git call here at the real repository instead of the -// temporary one. Strip them, or "git remote add origin" edits the repo running -// the test. -const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")), -); - - -const { - detectHost, - parseOwnerRepoFromRemote, - sanitizeProjectId, - generateUlid, - findRunFileByVendorId, - looksLikeTaskPath, - processPayload, - resolveEventName, - runsDir, - telemetryEnabled, -} = require("../../plugins/aidd-telemetry/hooks/journal.cjs"); - -const { taskFolderRelativePath } = require("../../plugins/aidd-telemetry/hooks/lib/file-writes.cjs"); - -const { - readSessionId, - VENDOR_FIELD_BY_HOST, - UNRECOGNISED_FILE_NAME, -} = require("../../plugins/aidd-telemetry/hooks/lib/record.cjs"); - -const { - getRepoRoot, - resolveRunsDir, -} = require("../../plugins/aidd-telemetry/hooks/lib/repo.cjs"); - -const { readCwd } = require("../../plugins/aidd-telemetry/hooks/lib/tools/index.cjs"); - -// One exact key set per line type. plugin_version is not optional here, because every test -// below runs against this repository's own readable manifest; the absent case is asserted -// on its own, against a temporary copy whose manifest this suite deletes. -const SESSION_START_KEYS = [ - "type", - "at", - "schema_version", - "run_id", - "project_id", - "project_remote", - "tool", - "vendor_id", - "vendor_field", - "plugin_version", -].sort(); - -// Read once, from the same manifest the hook itself reads - never hardcoded, so a real -// version bump never turns this suite red for a reason unrelated to what it tests. -const REAL_PLUGIN_VERSION = require("../../plugins/aidd-telemetry/.claude-plugin/plugin.json").version; - -const TURN_END_KEYS = ["type", "at"].sort(); -const TURN_END_WITH_PROMPT_KEYS = ["type", "at", "prompt_id"].sort(); -// `source` says how the path was known: "tool-stated" is the path the host handed us, -// "observed" is a file that changed in the task tree during the turn - the only way a -// write made through a shell command becomes visible at all. -const FILE_WRITTEN_KEYS = ["at", "path", "source", "type"]; - -const root = path.resolve(__dirname, "../.."); -const script = path.join(root, "plugins/aidd-telemetry/hooks/journal.cjs"); -const fixturesDir = path.join(__dirname, "fixtures"); - -function readFixture(name) { - return fs.readFileSync(path.join(fixturesDir, name), "utf8"); -} - -function loadFixture(name) { - return JSON.parse(readFixture(name)); -} - -// AIDD_RUNS_DIR is pinned to an isolated, never-created temp path rather than -// inherited from the environment, since these replays exercise the write -// path and must never land in a real directory this process can reach. -function replay(input, event = "session-start") { - return spawnSync(process.execPath, [script, event], { - cwd: root, - encoding: "utf8", - input, - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: path.join(os.tmpdir(), "aidd-telemetry-unused-runs") }, - }); -} - -const FIXTURE_NAMES = [ - "claude-code-session-start.json", - "codex-session-start.json", - "codex-session-end.json", - "copilot-session-start.json", - "cursor-session-start.json", - "claude-code-post-tool-use-write.json", - "claude-code-post-tool-use-edit.json", - "claude-code-post-tool-use-notebook-edit.json", - "claude-code-post-tool-use-bash.json", -]; - -// hooks.json's own event -> argv mapping; this is what every replay below -// drives, in place of `hook_event_name`. -const ARGV_EVENT_BY_HOOK_EVENT_NAME = { - SessionStart: "session-start", - Stop: "turn-end", - PostToolUse: "tool-used", -}; - -test("detectHost recognises the Claude Code fixture", () => { - assert.equal(detectHost(loadFixture("claude-code-session-start.json")), "claude-code"); -}); - -test("detectHost names each recognised host distinctly, not just null-vs-Claude-Code, since a supported-but-unwritten tool and a detection bug must stay distinguishable", () => { - assert.equal(detectHost(loadFixture("codex-session-start.json")), "codex"); - assert.equal(detectHost(loadFixture("copilot-session-start.json")), "copilot"); - assert.equal(detectHost(loadFixture("cursor-session-start.json")), "cursor"); -}); - -test("detectHost recognises Copilot's compat shape - session_id and hook_event_name spelled Claude Code's way, told apart by timestamp - on every event that shape sends", () => { - assert.equal(detectHost(loadFixture("copilot-compat-session-start.json")), "copilot"); - assert.equal(detectHost(loadFixture("copilot-compat-post-tool-use.json")), "copilot"); - assert.equal(detectHost(loadFixture("copilot-compat-turn-end.json")), "copilot"); -}); - -// Captured from a real Copilot session's own hook stdin, in a project declaring this -// plugin's PascalCase events. The compat shape is what these hooks actually receive, not a -// variant they might: every compat fixture was compared key-for-key against that stdin. -test("detectHost recognises the two compat shapes captured last, so all five of Copilot's events are pinned rather than three", () => { - assert.equal(detectHost(loadFixture("copilot-compat-user-prompt-submitted.json")), "copilot"); - assert.equal(detectHost(loadFixture("copilot-compat-pre-tool-use-skill.json")), "copilot"); -}); - -// Copilot's compat builder renames a built-in tool to Claude Code's PascalCase spelling but -// leaves `skill` lowercase, and the compat branch keys on that exact lowercase spelling: were -// `skill` renamed the way `Read` is, no step would open and a session would read unattributed. -test("a compat skill call names the tool in lowercase, unlike the PascalCase every built-in tool gets", () => { - const preToolUse = loadFixture("copilot-compat-pre-tool-use-skill.json"); - const postToolUse = loadFixture("copilot-compat-post-tool-use-skill.json"); - - assert.equal(preToolUse.tool_name, "skill"); - assert.equal(postToolUse.tool_name, "skill"); - // An object, never the JSON string the canonical builder sends. - assert.deepEqual(preToolUse.tool_input, { skill: "probe-echo" }); - assert.equal(typeof postToolUse.tool_input, "object"); -}); - -test("detectHost recognises both of Copilot's shapes as the same host, not one at the other's expense", () => { - assert.equal(detectHost(loadFixture("copilot-session-start.json")), "copilot"); - assert.equal(detectHost(loadFixture("copilot-compat-session-start.json")), "copilot"); -}); - -test("detectHost yields no host for an empty payload", () => { - assert.equal(detectHost({}), null); - assert.equal(detectHost(null), null); - assert.equal(detectHost(undefined), null); -}); - -test("detectHost yields no host when transcript_path matches neither shape", () => { - assert.equal( - detectHost({ - session_id: "x", - transcript_path: "/home/user/somewhere/else/notes.txt", - hook_event_name: "SessionStart", - }), - null, - ); -}); - -test("detectHost's compat rule does not claim Codex or Claude Code payloads - neither ever carries timestamp, only Copilot's compat shape does", () => { - assert.equal( - detectHost({ - session_id: "cc-1", - transcript_path: "/home/user/probe/cc-home/projects/-home-user-probe-project/cc-1.jsonl", - cwd: "/home/user/probe/project", - hook_event_name: "SessionStart", - source: "startup", - }), - "claude-code", - "unchanged by the compat rule: transcript_path claims this payload first", - ); - assert.equal( - detectHost({ - session_id: "codex-1", - transcript_path: - "/home/user/probe/codex-home/sessions/2026/08/14/rollout-2026-08-14T10-11-20-codex-1.jsonl", - cwd: "/home/user/probe/project", - hook_event_name: "SessionStart", - model: "probe-stub", - permission_mode: "bypassPermissions", - source: "startup", - }), - "codex", - "unchanged by the compat rule: transcript_path claims this payload first", - ); - assert.equal( - detectHost({ session_id: "x", hook_event_name: "SessionStart" }), - null, - "hook_event_name and session_id alone, with no timestamp and no transcript_path, must stay unrecognised - this is the exact shape the compat rule must not over-match", - ); -}); - -test("detectHost does not misattribute Codex to Claude Code when a path matches both shapes (narrower rule wins)", () => { - // Deliberately satisfies both patterns - a /projects/ segment (Claude - // Code's rule) and a /sessions////rollout- segment (Codex's) - - // so the ordering rule is actually load-bearing for this assertion. - assert.equal( - detectHost({ - transcript_path: - "/home/user/projects/scratch/.codex/sessions/2026/04/24/rollout-2026-04-24T10-00-00-abc123.jsonl", - hook_event_name: "SessionStart", - }), - "codex", - ); -}); - -// No fixture file for these: fixtures/README.md's contract is "recordings, -// not hand-written examples", and there is no Windows machine to record from. - -test("detectHost recognises a Claude Code transcript_path using backslash separators", () => { - assert.equal( - detectHost({ - session_id: "win-cc-1", - transcript_path: "C:\\Users\\me\\.claude\\projects\\-C-Users-me-project\\ffde6fda.jsonl", - hook_event_name: "SessionStart", - }), - "claude-code", - ); -}); - -test("detectHost recognises a Codex transcript_path using backslash separators", () => { - assert.equal( - detectHost({ - session_id: "win-codex-1", - transcript_path: "C:\\Users\\me\\.codex\\sessions\\2026\\08\\14\\rollout-2026-08-14T10-11-20-abc123.jsonl", - hook_event_name: "SessionStart", - }), - "codex", - ); -}); - -test("detectHost applies the Codex-before-Claude-Code ordering rule to a backslash path too", () => { - // The forward-slash equivalent of the ordering test above: both patterns - // are satisfiable by the same path once backslashes are normalised. - assert.equal( - detectHost({ - transcript_path: - "C:\\Users\\me\\projects\\scratch\\.codex\\sessions\\2026\\04\\24\\rollout-2026-04-24T10-00-00-abc123.jsonl", - hook_event_name: "SessionStart", - }), - "codex", - ); -}); - -test("replaying the Claude Code fixture exits 0 and prints nothing", () => { - const result = replay(readFixture("claude-code-session-start.json")); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); -}); - -for (const name of ["codex-session-start.json", "copilot-session-start.json", "cursor-session-start.json"]) { - test(`replaying the ${name} fixture exits 0 and prints nothing`, () => { - const result = replay(readFixture(name)); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - }); -} - -// Copilot's compat shape, one event per fixture, each replayed with the argv its own -// hooks.json entry passes (see ARGV_EVENT_BY_HOOK_EVENT_NAME) - the untouched capture -// itself going through journal.cjs's stdin path, not a payload built to match it. -for (const [name, event] of [ - ["copilot-compat-session-start.json", "session-start"], - ["copilot-compat-post-tool-use.json", "tool-used"], - ["copilot-compat-turn-end.json", "turn-end"], -]) { - test(`replaying the ${name} fixture exits 0 and prints nothing`, () => { - const result = replay(readFixture(name), event); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - }); -} - -for (const name of [ - "claude-code-post-tool-use-write.json", - "claude-code-post-tool-use-edit.json", - "claude-code-post-tool-use-notebook-edit.json", - "claude-code-post-tool-use-bash.json", -]) { - test(`replaying the ${name} fixture exits 0 and prints nothing`, () => { - const result = replay(readFixture(name), "tool-used"); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - }); -} - -test("replaying an empty payload exits 0", () => { - const result = replay(""); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); -}); - -test("replaying a truncated payload exits 0", () => { - const result = replay('{ "transcript_path": "/home/user/projects/x/y.jsonl", "hook_event'); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); -}); - -test("replaying a payload whose transcript_path matches neither shape exits 0", () => { - const result = replay( - JSON.stringify({ - session_id: "x", - transcript_path: "/home/user/somewhere/else/notes.txt", - hook_event_name: "SessionStart", - }), - ); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); -}); - -test("replaying with no stdin at all exits 0", () => { - const result = spawnSync(process.execPath, [script], { - cwd: root, - encoding: "utf8", - input: Buffer.alloc(0), - }); - assert.equal(result.status, 0); -}); - -test("resolveEventName trusts a recognised argv word outright, even against a disagreeing hook_event_name", () => { - assert.equal(resolveEventName("session-start", { hook_event_name: "Stop" }), "session-start"); - assert.equal(resolveEventName("turn-end", { hook_event_name: "SessionStart" }), "turn-end"); - assert.equal(resolveEventName("tool-used", {}), "tool-used"); -}); - -test("resolveEventName falls back to hook_event_name, mapped per its own spelling, only when argv is absent or unrecognised", () => { - assert.equal(resolveEventName(undefined, { hook_event_name: "SessionStart" }), "session-start"); - assert.equal(resolveEventName(undefined, { hook_event_name: "Stop" }), "turn-end"); - assert.equal(resolveEventName(undefined, { hook_event_name: "PostToolUse" }), "tool-used"); - assert.equal(resolveEventName(undefined, { hook_event_name: "sessionStart" }), "session-start"); // Cursor, Copilot - assert.equal(resolveEventName(undefined, { hook_event_name: "stop" }), "turn-end"); // Cursor - assert.equal(resolveEventName(undefined, { hook_event_name: "postToolUse" }), "tool-used"); // Cursor, Copilot - assert.equal(resolveEventName("not-a-real-event", { hook_event_name: "Stop" }), "turn-end"); -}); - -test("resolveEventName yields null when neither argv nor hook_event_name resolves to anything known", () => { - assert.equal(resolveEventName(undefined, {}), null); - assert.equal(resolveEventName(undefined, { hook_event_name: "SomethingElse" }), null); - assert.equal(resolveEventName(undefined, null), null); - // Cursor's own nicer afterFileEdit name is never produced for this hook - - // the CLI's translation always emits postToolUse instead - so it must not - // resolve to anything either. - assert.equal(resolveEventName(undefined, { hook_event_name: "afterFileEdit" }), null); -}); - -for (const name of FIXTURE_NAMES.filter((n) => n.endsWith("-session-start.json"))) { - test(`replaying ${name} with hook_event_name stripped from the payload still exits 0 - argv alone drives dispatch`, () => { - const payload = loadFixture(name); - delete payload.hook_event_name; - const result = replay(JSON.stringify(payload), "session-start"); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - }); -} - -for (const name of [ - "claude-code-post-tool-use-write.json", - "claude-code-post-tool-use-edit.json", - "claude-code-post-tool-use-notebook-edit.json", - "claude-code-post-tool-use-bash.json", -]) { - test(`replaying ${name} with hook_event_name stripped from the payload still exits 0 - argv alone drives dispatch`, () => { - const payload = loadFixture(name); - delete payload.hook_event_name; - const result = replay(JSON.stringify(payload), "tool-used"); - assert.equal(result.status, 0); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - }); -} - -// Codex has no `Stop` event: its own vocabulary is SessionStart / SessionEnd / PostToolUse, -// so a journal waiting for Stop records a session_start with nothing after it. Both fixtures -// are real captures with the home path redacted. -test("Codex's own SessionEnd payload closes the turn, since Codex never sends a Stop", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/codex-session-end.git" }); - try { - const start = { ...loadFixture("codex-session-start.json"), cwd: repo }; - const end = { ...loadFixture("codex-session-end.json"), cwd: repo }; - assert.equal(replayIn(start, "session-start").status, 0); - assert.equal(replayIn(end, "turn-end").status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1, "one run file, not one per event"); - const lines = readLines(written[0]); - assert.equal(lines[0].type, "session_start"); - assert.equal(lines[0].tool, "codex"); - assert.ok( - lines.some((line) => line.type === "turn_end"), - "SessionEnd must append a turn_end line - without it nothing downstream has a boundary" - ); - } finally { - cleanup(repo); - } -}); - -// The two events carry the same session, so a turn_end joins the session_start it closes. -// Asserted through the code that does the joining, not by comparing the two fixtures to each -// other: a test whose three assertions only read fixtures executes no product code and can -// only fail if someone edits a fixture. readSessionId reads transcript_path first and -// session_id second, and a real SessionEnd carries both. -test("Codex's SessionEnd resolves to the same session its SessionStart did", () => { - const { readSessionId } = require("../../plugins/aidd-telemetry/hooks/lib/tools/codex.cjs"); - const start = loadFixture("codex-session-start.json"); - const end = loadFixture("codex-session-end.json"); - - assert.equal(end.hook_event_name, "SessionEnd"); - assert.equal(readSessionId(end), readSessionId(start)); - // Not merely equal to each other: the resolved value is the session, not a stray field. - assert.equal(readSessionId(end), start.session_id); - // The fallback is exercised too - a SessionEnd with no transcript_path still resolves. - assert.equal(readSessionId({ ...end, transcript_path: undefined }), end.session_id); -}); - -test("a session-start replay with hook_event_name stripped still mints a record - argv alone drives dispatch", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/argv-only-start.git" }); - try { - const payload = makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-0000000000a9", event: "SessionStart" }); - delete payload.hook_event_name; - const result = replayIn(payload, "session-start"); - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 1); - } finally { - cleanup(repo); - } -}); - -test("a turn-end replay with hook_event_name stripped still appends a turn_end line - argv alone drives dispatch", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/argv-only-turn-end.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000aa"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - const written = readRunFiles(runsDirOf(repo)); - const before = readLines(written[0]); - - execFileSync("sleep", ["1.1"]); - - const payload = makePayload({ cwd: repo, sessionId, event: "Stop" }); - delete payload.hook_event_name; - const result = replayIn(payload, "turn-end"); - assert.equal(result.status, 0); - - const after = readLines(written[0]); - assert.equal(after.length, before.length + 1); - assert.equal(after[after.length - 1].type, "turn_end"); - } finally { - cleanup(repo); - } -}); - -test("a file-written replay with hook_event_name stripped still appends a file_written line - argv alone drives dispatch", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/argv-only-file-written.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000ab"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const filePath = writeIntoTaskFolder(repo, "2026_08_15_alpha"); - const payload = fileWrittenPayload({ cwd: repo, sessionId, filePath }); - delete payload.hook_event_name; - const result = replayIn(payload, "tool-used"); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines[lines.length - 1].type, "file_written"); - assert.equal(lines[lines.length - 1].path, "aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"); - } finally { - cleanup(repo); - } -}); - - -test("the Cursor fixture's user_email is the redaction placeholder", () => { - const cursor = loadFixture("cursor-session-start.json"); - assert.equal(cursor.user_email, "user@example.com"); -}); - -test("the Cursor post-tool-use fixture's user_email is the redaction placeholder too - the field the earlier check never reached", () => { - const cursor = loadFixture("cursor-post-tool-use.json"); - assert.equal(cursor.user_email, "user@example.com"); -}); - -test("parseOwnerRepoFromRemote reads owner/repo out of an SSH remote", () => { - assert.equal(parseOwnerRepoFromRemote("git@github.com:ai-driven-dev/framework.git"), "ai-driven-dev/framework"); -}); - -test("parseOwnerRepoFromRemote reads owner/repo out of an HTTPS remote", () => { - assert.equal(parseOwnerRepoFromRemote("https://github.com/ai-driven-dev/framework.git"), "ai-driven-dev/framework"); -}); - -test("parseOwnerRepoFromRemote handles a remote with no .git suffix", () => { - assert.equal(parseOwnerRepoFromRemote("https://github.com/ai-driven-dev/framework"), "ai-driven-dev/framework"); -}); - -test("parseOwnerRepoFromRemote collapses a subgroup path to its last two segments", () => { - assert.equal(parseOwnerRepoFromRemote("https://gitlab.com/group/subgroup/repo.git"), "subgroup/repo"); -}); - -test("parseOwnerRepoFromRemote yields null for a remote it cannot parse", () => { - assert.equal(parseOwnerRepoFromRemote("not a remote"), null); - assert.equal(parseOwnerRepoFromRemote(""), null); - assert.equal(parseOwnerRepoFromRemote(null), null); - assert.equal(parseOwnerRepoFromRemote(undefined), null); -}); - -test("sanitizeProjectId keeps a clean owner/repo untouched", () => { - assert.equal(sanitizeProjectId("ai-driven-dev/framework"), "ai-driven-dev/framework"); -}); - -test("sanitizeProjectId neutralises unsafe characters per segment", () => { - assert.equal(sanitizeProjectId("weird name/../repo"), "weird-name/-/repo"); -}); - -test("generateUlid produces a 26-character Crockford base32 string", () => { - const id = generateUlid(); - assert.equal(id.length, 26); - assert.match(id, /^[0-9A-HJKMNP-TV-Z]{26}$/u); -}); - -test("generateUlid mints a different id on each call", () => { - assert.notEqual(generateUlid(), generateUlid()); -}); - -// Restores AIDD_RUNS_DIR to exactly what it was before, including truly -// absent rather than the string "undefined". -function withRunsDirEnv({ set = {}, unset = [] }, fn) { - const keys = ["AIDD_RUNS_DIR"]; - const original = {}; - for (const key of keys) original[key] = process.env[key]; - try { - for (const key of unset) delete process.env[key]; - for (const [key, value] of Object.entries(set)) process.env[key] = value; - return fn(); - } finally { - for (const key of keys) { - if (original[key] === undefined) delete process.env[key]; - else process.env[key] = original[key]; - } - } -} - -test("runsDir defaults to /aidd_docs/runs when AIDD_RUNS_DIR is unset", () => { - withRunsDirEnv({ unset: ["AIDD_RUNS_DIR"] }, () => { - assert.equal(runsDir("/repo"), path.join("/repo", "aidd_docs", "runs")); - }); -}); - -test("AIDD_RUNS_DIR overrides the in-repo default outright", () => { - withRunsDirEnv({ set: { AIDD_RUNS_DIR: "/custom/runs" } }, () => { - assert.equal(runsDir("/repo"), "/custom/runs"); - }); -}); - -// A worktree gets its own journal by decision, and this also proves --show-toplevel behaves -// the way resolveRunsDir assumes rather than merely asserting the assumption. -function addWorktree(main, dir) { - execFileSync("git", ["add", "-A"], { cwd: main, env: CLEAN_ENV }); - execFileSync("git", ["commit", "-q", "-m", "init", "--allow-empty"], { - cwd: main, - env: CLEAN_ENV, - }); - execFileSync("git", ["worktree", "add", "-b", "feature", dir], { cwd: main, env: CLEAN_ENV }); -} - -// git's --show-toplevel resolves symlinks and, on Windows, answers with a long-filename -// path that can differ from fs.realpathSync()'s when %TEMP% resolves through an 8.3 alias. -// `.native` asks the OS for the canonical form; lowercased on win32, where paths are -// case-insensitive. -function canonicalPath(target) { - const resolved = fs.realpathSync.native(target); - return process.platform === "win32" ? resolved.toLowerCase() : resolved; -} - -test("getRepoRoot resolves a worktree to itself, never to the repository it shares", () => { - const main = makeTempRepo(); - const worktree = path.join(makeTempDir("aidd-telemetry-worktree-"), "wt"); - addWorktree(main, worktree); - - const worktreeRoot = getRepoRoot(worktree); - - assert.equal(canonicalPath(worktreeRoot), canonicalPath(worktree)); - assert.notEqual(canonicalPath(worktreeRoot), canonicalPath(getRepoRoot(main))); -}); - -test("resolveRunsDir writes a worktree's journal under the worktree, not the main checkout", () => { - const main = makeTempRepo(); - const worktree = path.join(makeTempDir("aidd-telemetry-worktree-"), "wt"); - addWorktree(main, worktree); - writeTelemetryConfig(worktree, { enabled: true }); - fs.mkdirSync(runsDirOf(worktree), { recursive: true }); - - const target = resolveRunsDir(worktree); - - assert.ok(target, "resolveRunsDir must resolve inside a worktree"); - assert.equal(canonicalPath(target.repoRoot), canonicalPath(worktree)); - assert.equal(canonicalPath(target.dir), canonicalPath(runsDirOf(worktree))); - assert.notEqual(canonicalPath(target.dir), canonicalPath(runsDirOf(main))); -}); - -function makeTempDir(prefix) { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -// `withConfig` is independent of `withRunsDir`: the switch and the location it writes to do -// not have to move together. Defaults to a switched-on repository. -function makeTempRepo({ remote, withRunsDir = true, withConfig = true } = {}) { - const dir = makeTempDir("aidd-telemetry-repo-"); - execFileSync("git", ["init", "-q"], { cwd: dir, env: CLEAN_ENV }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, env: CLEAN_ENV }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, env: CLEAN_ENV }); - if (remote) { - execFileSync("git", ["remote", "add", "origin", remote], { cwd: dir, env: CLEAN_ENV }); - } - if (withRunsDir) { - fs.mkdirSync(path.join(dir, "aidd_docs", "runs"), { recursive: true }); - } - if (withConfig) { - writeTelemetryConfig(dir, { enabled: true }); - } - return dir; -} - -function writeTelemetryConfig(repo, { enabled = true, endpoint = "http://127.0.0.1:4318", raw } = {}) { - const dir = path.join(repo, ".aidd"); - fs.mkdirSync(dir, { recursive: true }); - const content = raw !== undefined ? raw : JSON.stringify({ telemetry: { enabled, endpoint } }); - fs.writeFileSync(path.join(dir, "config.json"), content); -} - -function runsDirOf(repo) { - return path.join(repo, "aidd_docs", "runs"); -} - -function makePayload({ cwd, sessionId, event }) { - return { - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd, - hook_event_name: event, - source: "startup", - }; -} - -// `event` defaults from the payload's own hook_event_name, overridable for a test exercising -// a disagreement. AIDD_RUNS_DIR is set to "", which runsDir treats as unset, so an ambient -// override in this process's real environment can never leak in. -function replayIn(payload, event = ARGV_EVENT_BY_HOOK_EVENT_NAME[payload.hook_event_name]) { - const args = event ? [script, event] : [script]; - return spawnSync(process.execPath, args, { - cwd: root, - encoding: "utf8", - input: JSON.stringify(payload), - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: "" }, - }); -} - -// The real hook process, run exactly as replayIn does, except the person running it has set -// AIDD_TELEMETRY=0 in their own shell - the one refusal available at their scope, which must -// win even in a repository whose tracked .aidd/config.json turns measurement on. -function replayInRefusing(payload, event = ARGV_EVENT_BY_HOOK_EVENT_NAME[payload.hook_event_name]) { - const args = event ? [script, event] : [script]; - return spawnSync(process.execPath, args, { - cwd: root, - encoding: "utf8", - input: JSON.stringify(payload), - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: "", AIDD_TELEMETRY: "0" }, - }); -} - -// No payload at all - empty stdin, so `readStdin` yields "" and `payload` stays null. The -// counterpart to replayIn: proves the "no payload arrived" state, not merely one host -// among several failing to recognise a shape. -function replayEmpty(event = "session-start") { - return spawnSync(process.execPath, [script, event], { - cwd: root, - encoding: "utf8", - input: "", - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: "" }, - }); -} - -// Spawned with the process's own cwd set to the target repo, unlike replayIn (which always -// runs from `root` and relies on the payload naming its own cwd) - the one way to prove -// handleUnrecognisedPayload's process.cwd() fallback, which reads the hook's own cwd, not -// a field in the payload. -function replayInAt(cwd, payload, event) { - const args = event ? [script, event] : [script]; - return spawnSync(process.execPath, args, { - cwd, - encoding: "utf8", - input: JSON.stringify(payload), - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: "" }, - }); -} - -// The one replay that does NOT strip GIT_*: it hands the hook the poisoned environment -// git itself exports, which is the only way to exercise the hook's own defence. -function replayInWithGitDir(payload, gitDir) { - return spawnSync(process.execPath, [script, "session-start"], { - cwd: root, - encoding: "utf8", - input: JSON.stringify(payload), - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: "", GIT_DIR: gitDir }, - }); -} - -// Run files are `.jsonl` - one line per observation, appended, never rewritten. Recurses -// because AIDD_RUNS_DIR can point anywhere. -function readRunFiles(dir) { - const files = []; - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return files; - } - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...readRunFiles(full)); - } else if (entry.name.endsWith(".jsonl")) { - files.push(full); - } - } - return files; -} - -// One parsed object per non-empty line, in file order. -function readLines(filePath) { - return fs - .readFileSync(filePath, "utf8") - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line)); -} - -function cleanup(...dirs) { - for (const dir of dirs) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -test("a session writes nothing and exits 0 when .aidd/config.json is absent, even with aidd_docs/runs/ present", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/no-config.git", withConfig: false }); - try { - const result = replayIn( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-000000000001", event: "SessionStart" }), - ); - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("aidd_docs/runs/ is no longer a permission: a switched-on session creates it on demand when it does not exist yet", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/dir-on-demand.git", withRunsDir: false }); - try { - assert.equal(fs.existsSync(runsDirOf(repo)), false); - const result = replayIn( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-0000000000dm", event: "SessionStart" }), - ); - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 1); - } finally { - cleanup(repo); - } -}); - -test("an unparseable .aidd/config.json means off, and the hook exits 0", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/bad-config.git", withConfig: false }); - writeTelemetryConfig(repo, { raw: "{ this is not json" }); - try { - const result = replayIn( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-0000000000bc", event: "SessionStart" }), - ); - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("a config.json that cannot be read at all (a directory in its place) means off, and the hook exits 0", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/unreadable-config.git", withConfig: false }); - // A directory named config.json: readFileSync throws EISDIR, deterministically, - // standing in for any read error a real filesystem could hand back. - fs.mkdirSync(path.join(repo, ".aidd", "config.json"), { recursive: true }); - try { - const result = replayIn( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-0000000000rf", event: "SessionStart" }), - ); - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("telemetry.enabled: false means off - nothing written", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/disabled-config.git", withConfig: false }); - writeTelemetryConfig(repo, { enabled: false }); - try { - const result = replayIn( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-0000000000df", event: "SessionStart" }), - ); - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("AIDD off but the provider exporting: the journal still writes nothing - the case the switch exists for", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/provider-exporting.git", withConfig: false }); - try { - const result = spawnSync( - process.execPath, - [script, "session-start"], - { - cwd: root, - encoding: "utf8", - input: JSON.stringify( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-0000000000pe", event: "SessionStart" }), - ), - // The provider's own export switches, on, with AIDD's own switch absent - - // the journal must not key off any of these. - env: { - ...CLEAN_ENV, - AIDD_RUNS_DIR: "", - CLAUDE_CODE_ENABLE_TELEMETRY: "1", - OTEL_METRICS_EXPORTER: "otlp", - OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318", - }, - }, - ); - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("turning telemetry off mid-session stops the very next write, with no restart - the switch is read at the point of use, never cached", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/mid-session-off.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000ms1"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const before = fs.readFileSync(written[0]); - - execFileSync("sleep", ["1.1"]); - - writeTelemetryConfig(repo, { enabled: false }); - - const result = replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - assert.equal(result.status, 0); - - // Byte-identical, not merely deepEqual once parsed: no line - not even - // a changed field on an existing one - may land once the switch is off. - const after = fs.readFileSync(written[0]); - assert.ok(after.equals(before), "no line may be appended once telemetry is off, with no restart needed"); - } finally { - cleanup(repo); - } -}); - -test("telemetryEnabled requires enabled strictly === true, not merely truthy", () => { - const repo = makeTempDir("aidd-telemetry-strict-"); - try { - writeTelemetryConfig(repo, { raw: JSON.stringify({ telemetry: { enabled: "true" } }) }); - assert.equal(telemetryEnabled(repo), false, "a string 'true' must not enable telemetry"); - - writeTelemetryConfig(repo, { raw: JSON.stringify({ telemetry: { enabled: 1 } }) }); - assert.equal(telemetryEnabled(repo), false, "a truthy number must not enable telemetry"); - - writeTelemetryConfig(repo, { raw: JSON.stringify({ telemetry: null }) }); - assert.equal(telemetryEnabled(repo), false, "a null telemetry key must not enable telemetry"); - - writeTelemetryConfig(repo, { raw: JSON.stringify({}) }); - assert.equal(telemetryEnabled(repo), false, "a missing telemetry key must not enable telemetry"); - - writeTelemetryConfig(repo, { enabled: true }); - assert.equal(telemetryEnabled(repo), true); - } finally { - cleanup(repo); - } -}); - -test("telemetryEnabled is off for a repo root with no .aidd/config.json at all", () => { - const dir = makeTempDir("aidd-telemetry-no-config-"); - try { - assert.equal(telemetryEnabled(dir), false); - } finally { - cleanup(dir); - } -}); - -test("AIDD_TELEMETRY=0 refuses unconditionally, even when the project's own switch is on", () => { - const repo = makeTempDir("aidd-telemetry-refusal-"); - const original = process.env.AIDD_TELEMETRY; - try { - writeTelemetryConfig(repo, { enabled: true }); - assert.equal(telemetryEnabled(repo), true, "sanity: on without a refusal"); - - process.env.AIDD_TELEMETRY = "0"; - assert.equal(telemetryEnabled(repo), false, "the refusal wins over the project's file"); - } finally { - if (original === undefined) delete process.env.AIDD_TELEMETRY; - else process.env.AIDD_TELEMETRY = original; - cleanup(repo); - } -}); - -test("AIDD_TELEMETRY unset, empty, or any other value is not a choice — never a refusal", () => { - const repo = makeTempDir("aidd-telemetry-non-refusal-"); - const original = process.env.AIDD_TELEMETRY; - try { - writeTelemetryConfig(repo, { enabled: true }); - for (const value of [undefined, "", "1", "false", "no"]) { - if (value === undefined) delete process.env.AIDD_TELEMETRY; - else process.env.AIDD_TELEMETRY = value; - assert.equal(telemetryEnabled(repo), true, `AIDD_TELEMETRY=${JSON.stringify(value)} must not refuse`); - } - } finally { - if (original === undefined) delete process.env.AIDD_TELEMETRY; - else process.env.AIDD_TELEMETRY = original; - cleanup(repo); - } -}); - -test("a session writes exactly one file directly under aidd_docs/runs/ when opted in, its session_start line carrying exactly the documented keys", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/opted-in.git" }); - try { - const sessionId = "00000000-0000-4000-8000-000000000002"; - const result = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(result.status, 0); - - const runsPath = runsDirOf(repo); - const written = readRunFiles(runsPath); - assert.equal(written.length, 1); - assert.equal(path.dirname(written[0]), runsPath); - - const lines = readLines(written[0]); - assert.equal(lines.length, 1); - const line = lines[0]; - assert.deepEqual(Object.keys(line).sort(), SESSION_START_KEYS); - assert.equal(line.type, "session_start"); - assert.equal(line.schema_version, 2); - assert.equal(line.plugin_version, REAL_PLUGIN_VERSION); - assert.equal(line.project_id, "acme/opted-in"); - assert.equal(line.project_remote, "git@github.com:acme/opted-in.git"); - assert.equal(line.tool, "claude-code"); - assert.equal(line.vendor_id, sessionId); - assert.equal(line.vendor_field, "session.id"); - assert.match(line.run_id, /^[0-9A-HJKMNP-TV-Z]{26}$/u); - assert.equal(path.basename(written[0], ".jsonl"), `${line.run_id}__${sessionId}`); - assert.match(line.at, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u); - } finally { - cleanup(repo); - } -}); - -test("a person's AIDD_TELEMETRY=0 refuses recording end to end, in a repo whose tracked switch turns it on, writing no file and leaving the tree clean", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/refused.git" }); - try { - const sessionId = "00000000-0000-4000-8000-000000000009"; - const before = execFileSync("git", ["status", "--porcelain"], { cwd: repo, env: CLEAN_ENV }).toString(); - - const result = replayInRefusing(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - assert.equal(result.status, 0, "a refusal is not an error — the hook still exits clean"); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0, "nothing is recorded once refused"); - const after = execFileSync("git", ["status", "--porcelain"], { cwd: repo, env: CLEAN_ENV }).toString(); - assert.equal(after, before, "refusing changes no file — the working tree stays exactly as it was"); - } finally { - cleanup(repo); - } -}); - -test("removing the refusal records again, in the same repo whose switch never changed", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/re-recorded.git" }); - try { - const refused = replayInRefusing( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-00000000000a", event: "SessionStart" }), - ); - assert.equal(refused.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - - const recorded = replayIn( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-00000000000b", event: "SessionStart" }), - ); - assert.equal(recorded.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 1, "recorded again once the refusal is gone"); - } finally { - cleanup(repo); - } -}); - -test( - "SessionStart creates the run directory 0700 and the record file 0600 on POSIX", - { skip: process.platform === "win32" ? "POSIX mode bits do not apply on win32" : false }, - () => { - // Forces umask 0 so 0700/0600 are exactly what lands, not whatever a - // permissive machine default umask happened to allow. - const originalUmask = process.umask(0); - const repo = makeTempRepo({ remote: "git@github.com:acme/perms.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000pm"; - const result = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - - const fileMode = fs.statSync(written[0]).mode & 0o777; - assert.equal(fileMode, 0o600, `record file mode was 0${fileMode.toString(8)}, expected 0600`); - - const dirMode = fs.statSync(path.dirname(written[0])).mode & 0o777; - assert.equal(dirMode, 0o700, `run directory mode was 0${dirMode.toString(8)}, expected 0700`); - } finally { - process.umask(originalUmask); - cleanup(repo); - } - }, -); - -test("the whitelist: an eleventh key on either line type would fail this assertion", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/whitelist.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000wl01"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const lines = readLines(written[0]); - assert.equal(lines.length, 2); - assert.deepEqual(Object.keys(lines[0]).sort(), SESSION_START_KEYS); - assert.deepEqual(Object.keys(lines[1]).sort(), TURN_END_KEYS); - } finally { - cleanup(repo); - } -}); - -test("turn_end carries prompt_id when the Stop payload provides one", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/prompt-id-present.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000pi01"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const payload = makePayload({ cwd: repo, sessionId, event: "Stop" }); - payload.prompt_id = "prompt-001"; - const result = replayIn(payload); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - const turnEnd = lines[lines.length - 1]; - assert.equal(turnEnd.type, "turn_end"); - assert.equal(turnEnd.prompt_id, "prompt-001"); - assert.deepEqual(Object.keys(turnEnd).sort(), TURN_END_WITH_PROMPT_KEYS); - } finally { - cleanup(repo); - } -}); - -test("turn_end omits prompt_id, rather than writing null, when the payload carries none - the case every host observed today falls into", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/prompt-id-absent.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000pi02"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); // no prompt_id field at all - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - const turnEnd = lines[lines.length - 1]; - assert.equal(turnEnd.type, "turn_end"); - assert.equal(Object.prototype.hasOwnProperty.call(turnEnd, "prompt_id"), false); - assert.deepEqual(Object.keys(turnEnd).sort(), TURN_END_KEYS); - } finally { - cleanup(repo); - } -}); - -test("no written line contains ended_at, tasks, parent_run_id or task_id - the fields the event log leaves out", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/no-forbidden-fields.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000ff01"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_15_alpha") })); - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines.length, 3); - for (const line of lines) { - for (const forbidden of ["ended_at", "tasks", "parent_run_id", "task_id"]) { - assert.equal( - Object.prototype.hasOwnProperty.call(line, forbidden), - false, - `"${forbidden}" must never appear on a ${line.type} line`, - ); - } - } - } finally { - cleanup(repo); - } -}); - -test("no written value is a token count, a cost, a model name, or a duration", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/no-measurement.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000nm01"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - - const forbiddenKeys = [ - "tokens", - "token_count", - "input_tokens", - "output_tokens", - "cost", - "cost_usd", - "model", - "model_name", - "duration", - "duration_ms", - "elapsed", - "elapsed_ms", - ]; - for (const line of lines) { - for (const key of forbiddenKeys) { - assert.equal(Object.prototype.hasOwnProperty.call(line, key), false, `${line.type} must not carry "${key}"`); - } - for (const [key, value] of Object.entries(line)) { - if (key === "schema_version") { - assert.equal(typeof value, "number"); - } else { - assert.equal(typeof value, "string", `"${key}" must be a string, not a measured quantity`); - } - } - } - } finally { - cleanup(repo); - } -}); - -test("ten turns in one session produce one file, not ten, and its lines record real time passing", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/ten-turns.git" }); - try { - const sessionId = "00000000-0000-4000-8000-000000000003"; - const start = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(start.status, 0); - - const runsPath = runsDirOf(repo); - const afterStart = readRunFiles(runsPath); - assert.equal(afterStart.length, 1); - const startLine = readLines(afterStart[0])[0]; - - // nowIso() truncates to whole seconds, so a Stop replayed inside the same second would - // not visibly move `at` even when handleTurnEnd ran correctly. - execFileSync("sleep", ["1.1"]); - - for (let i = 0; i < 9; i++) { - const stop = replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - assert.equal(stop.status, 0); - } - - const written = readRunFiles(runsPath); - assert.equal(written.length, 1, "nine Stop events must append to the one file, never mint a second"); - - const lines = readLines(written[0]); - assert.equal(lines.length, 10, "one session_start line plus nine turn_end lines"); - assert.equal(lines[0].run_id, startLine.run_id); - const lastTurnEnd = lines[lines.length - 1]; - assert.equal(lastTurnEnd.type, "turn_end"); - assert.ok( - new Date(lastTurnEnd.at) > new Date(startLine.at), - `last turn_end.at (${lastTurnEnd.at}) did not advance past session_start.at (${startLine.at})`, - ); - } finally { - cleanup(repo); - } -}); - -test("a second SessionStart for the same session does not mint a second file, or a second session_start line", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/resumed-session.git" }); - try { - const sessionId = "00000000-0000-4000-8000-000000000006"; - const first = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(first.status, 0); - - const runsPath = runsDirOf(repo); - const afterFirst = readRunFiles(runsPath); - assert.equal(afterFirst.length, 1); - const firstLines = readLines(afterFirst[0]); - assert.equal(firstLines.length, 1); - const runIdAfterFirst = firstLines[0].run_id; - - const second = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(second.status, 0); - - const afterSecond = readRunFiles(runsPath); - assert.equal(afterSecond.length, 1); - const secondLines = readLines(afterSecond[0]); - assert.equal(secondLines.length, 1, "a resumed SessionStart must not append a duplicate session_start line"); - assert.equal(secondLines[0].run_id, runIdAfterFirst); - } finally { - cleanup(repo); - } -}); - -test("a session exits 0 and writes nothing when git is unavailable", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/no-git.git" }); - try { - const result = spawnSync(process.execPath, [script], { - cwd: root, - encoding: "utf8", - input: JSON.stringify( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-000000000007", event: "SessionStart" }), - ), - // Empty PATH makes spawnSync("git", ...) fail with ENOENT inside the hook. - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: "", PATH: "" }, - }); - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("a session exits 0 when the run directory cannot be created", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/blocked-write.git" }); - const blockerParent = makeTempDir("aidd-telemetry-blocker-"); - // A regular file where AIDD_RUNS_DIR expects a directory: mkdirSync under - // it throws ENOTDIR, standing in for "no write permission". - const blockerFile = path.join(blockerParent, "not-a-directory"); - fs.writeFileSync(blockerFile, ""); - try { - const result = spawnSync(process.execPath, [script], { - cwd: root, - encoding: "utf8", - input: JSON.stringify( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-000000000008", event: "SessionStart" }), - ), - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: blockerFile }, - }); - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - } finally { - cleanup(repo, blockerParent); - } -}); - -test("a SessionStart with no session_id exits 0 and writes nothing, rather than a nine-key record", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/no-session-id.git" }); - try { - const payload = { - transcript_path: "/home/user/probe/cc-home/projects/-home-user-probe-project/no-session-id.jsonl", - cwd: repo, - hook_event_name: "SessionStart", - source: "startup", - // session_id deliberately absent - }; - const result = replayIn(payload); - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("a Stop with no session_id exits 0 and writes nothing", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/no-session-id-stop.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000f6"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const before = fs.readFileSync(written[0]); - - const payload = { - transcript_path: "/home/user/probe/cc-home/projects/-home-user-probe-project/no-session-id.jsonl", - cwd: repo, - hook_event_name: "Stop", - source: "startup", - // session_id deliberately absent - }; - const result = replayIn(payload); - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - - const after = readRunFiles(runsDirOf(repo)); - assert.equal(after.length, 1); - assert.ok(fs.readFileSync(after[0]).equals(before)); - } finally { - cleanup(repo); - } -}); - -test("a Stop exits 0 and writes nothing when no file was ever minted for the session", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/stop-no-file.git" }); - try { - const result = replayIn( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-0000000000f1", event: "Stop" }), - ); - assert.equal(result.status, 0); - assert.equal(result.stderr, ""); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("a Stop still appends its line even when the run file's existing content is not valid JSON - turn-end never parses the file it appends to", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/corrupted-record.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000f2"; - const start = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(start.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - // A corrupted earlier line, still newline-terminated (a truncated-last-line - // scenario is covered separately, further down). - fs.appendFileSync(written[0], "{ this is not valid json\n"); - const corrupted = fs.readFileSync(written[0]); - - const stop = replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - assert.equal(stop.status, 0); - assert.equal(stop.stderr, ""); - - const after = fs.readFileSync(written[0]); - assert.ok( - after.subarray(0, corrupted.length).equals(corrupted), - "the corrupted content must survive untouched - turn-end only ever appends", - ); - assert.ok(after.length > corrupted.length, "turn-end must still append its own line after a corrupted one"); - } finally { - cleanup(repo); - } -}); - -test("a session that never produces a git commit still yields a complete session_start line", () => { - // makeTempRepo runs `git init` and configures identity but never commits, so a repository - // with no HEAD is the shape under test here. - const repo = makeTempRepo({ remote: "git@github.com:acme/no-commit.git" }); - try { - const log = spawnSync("git", ["log"], { cwd: repo, encoding: "utf8", env: CLEAN_ENV }); - assert.notEqual(log.status, 0); // no commits exist - - const sessionId = "00000000-0000-4000-8000-0000000000f3"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const sessionStart = readLines(written[0])[0]; - assert.deepEqual(Object.keys(sessionStart).sort(), SESSION_START_KEYS); - for (const key of SESSION_START_KEYS) { - assert.notEqual(sessionStart[key], undefined, `"${key}" must be present even with no commit in the repo`); - } - } finally { - cleanup(repo); - } -}); - - -test("vendor_field names the export-side attribute, and vendor_id is the same session.id value a live export would carry", () => { - // vendor_id is exactly the payload's session_id, the same value Claude - // Code's own OTEL export carries as session.id - not a hook-side derivative. - const repo = makeTempRepo({ remote: "git@github.com:acme/vendor-field.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000f5"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const written = readRunFiles(runsDirOf(repo)); - const line = readLines(written[0])[0]; - assert.equal(line.vendor_field, "session.id"); - assert.notEqual(line.vendor_field, "session_id"); // not the hook-side field name - assert.equal(line.vendor_id, sessionId); - } finally { - cleanup(repo); - } -}); - -test("two repositories with different remotes each write into their own aidd_docs/runs/, keyed on the repository root rather than project_id", () => { - const repoA = makeTempRepo({ remote: "git@github.com:acme/repo-a.git" }); - const repoB = makeTempRepo({ remote: "git@github.com:acme/repo-b.git" }); - try { - replayIn( - makePayload({ cwd: repoA, sessionId: "00000000-0000-4000-8000-0000000000a1", event: "SessionStart" }), - ); - replayIn( - makePayload({ cwd: repoB, sessionId: "00000000-0000-4000-8000-0000000000b1", event: "SessionStart" }), - ); - - assert.equal(readRunFiles(runsDirOf(repoA)).length, 1); - assert.equal(readRunFiles(runsDirOf(repoB)).length, 1); - } finally { - cleanup(repoA, repoB); - } -}); - -test("a repository with no remote still produces one record, project_id keyed on its basename and project_remote null - the path itself no longer depends on either", () => { - const repo = makeTempRepo({}); - const basename = path.basename(repo); - try { - const sessionId = "00000000-0000-4000-8000-000000000005"; - const result = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const line = readLines(written[0])[0]; - assert.equal(line.project_id, basename); - assert.equal(line.project_remote, null); - } finally { - cleanup(repo); - } -}); - -test("findRunFileByVendorId locates the file by filename alone, ignoring file contents entirely", () => { - const dir = makeTempDir("aidd-telemetry-lookup-"); - try { - const runIdA = generateUlid(); - const runIdB = generateUlid(); - // Deliberately invalid JSON: finding run B anyway proves the match is by - // filename, not by content. - fs.writeFileSync(path.join(dir, `${runIdA}__session-a.jsonl`), "not json at all {{{"); - fs.writeFileSync(path.join(dir, `${runIdB}__session-b.jsonl`), "not json at all {{{"); - - assert.equal(findRunFileByVendorId(dir, "session-b"), path.join(dir, `${runIdB}__session-b.jsonl`)); - assert.equal(findRunFileByVendorId(dir, "session-missing"), null); - assert.equal(findRunFileByVendorId(path.join(dir, "nowhere"), "session-a"), null); - } finally { - cleanup(dir); - } -}); - -test("findRunFileByVendorId does not mistake a vendor_id containing the filename separator for a different session's file", () => { - // A filename split on the first/last "__" would let vendor_id "b" match a - // file actually written for vendor_id "a__b" (or vice versa). - const dir = makeTempDir("aidd-telemetry-lookup-sep-"); - try { - const runId = generateUlid(); - fs.writeFileSync(path.join(dir, `${runId}__a__b.jsonl`), "irrelevant"); - - assert.equal(findRunFileByVendorId(dir, "b"), null); - assert.equal(findRunFileByVendorId(dir, "a"), null); - assert.equal(findRunFileByVendorId(dir, "a__b"), path.join(dir, `${runId}__a__b.jsonl`)); - } finally { - cleanup(dir); - } -}); - -test("findRunFileByVendorId ignores leftover pre-event-log .json files (both the bare .json and the old __.json shapes), matching only .jsonl", () => { - const dir = makeTempDir("aidd-telemetry-lookup-legacy-"); - try { - const legacyBareRunId = generateUlid(); - fs.writeFileSync(path.join(dir, `${legacyBareRunId}.json`), JSON.stringify({ vendor_id: "session-legacy" })); - - const legacyTenKeyRunId = generateUlid(); - fs.writeFileSync( - path.join(dir, `${legacyTenKeyRunId}__session-legacy.json`), - JSON.stringify({ vendor_id: "session-legacy", schema_version: 1 }), - ); - - assert.equal(findRunFileByVendorId(dir, "session-legacy"), null); - - // A real .jsonl file for the same vendor_id, sitting alongside the two - // legacy leftovers, is still found - the extension is what gates a - // match, not merely the absence of a same-vendor legacy file. - const runId = generateUlid(); - fs.writeFileSync(path.join(dir, `${runId}__session-legacy.jsonl`), '{"type":"session_start"}\n'); - assert.equal(findRunFileByVendorId(dir, "session-legacy"), path.join(dir, `${runId}__session-legacy.jsonl`)); - } finally { - cleanup(dir); - } -}); - -// Restores process.env exactly, including "unset" when a key did not exist. Drives -// processPayload in-process, since counting git invocations observes this process's PATH. -function withEnv(overrides, fn) { - const original = {}; - for (const key of Object.keys(overrides)) { - original[key] = process.env[key]; - process.env[key] = overrides[key]; - } - try { - return fn(); - } finally { - for (const key of Object.keys(overrides)) { - if (original[key] === undefined) delete process.env[key]; - else process.env[key] = original[key]; - } - } -} - -// Counts real `git` invocations made while fn() runs, via the module-level -// spawnSync patch above. -function countGitInvocations(fn) { - const counter = { count: 0 }; - const previous = gitCallCounter; - gitCallCounter = counter; - try { - fn(); - } finally { - gitCallCounter = previous; - } - return counter.count; -} - -test("a Stop shells out to git no more times with several hundred run files on disk than with one", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/git-count.git" }); - const sessionId = "git-count-session"; - try { - withEnv({ AIDD_RUNS_DIR: "" }, () => { - processPayload(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const dir = runsDirOf(repo); - - const callsWithOne = countGitInvocations(() => { - processPayload(makePayload({ cwd: repo, sessionId, event: "Stop" })); - }); - assert.ok( - callsWithOne > 0, - "the wrapper observed no git call - this test is not exercising the code path it claims to", - ); - - for (let i = 0; i < 300; i++) { - const runId = generateUlid(); - fs.writeFileSync(path.join(dir, `${runId}__seed-${i}.jsonl`), "irrelevant, never read"); - } - - const callsWithMany = countGitInvocations(() => { - processPayload(makePayload({ cwd: repo, sessionId, event: "Stop" })); - }); - - assert.equal(callsWithMany, callsWithOne); - }); - } finally { - cleanup(repo); - } -}); - -test("file-written shells out to git zero times for a tool it does not track - the common case, since this fires on every tool call", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/file-written-reject-count.git" }); - const sessionId = "file-written-reject-session"; - try { - withEnv({ AIDD_RUNS_DIR: "" }, () => { - processPayload(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const callsForBash = countGitInvocations(() => { - processPayload({ - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: "PostToolUse", - tool_name: "Bash", - tool_input: { command: "echo hi", description: "echo" }, - }); - }); - assert.equal(callsForBash, 0, "a non-write tool_name must reject before any git shellout"); - - const callsForUnrelatedWrite = countGitInvocations(() => { - processPayload({ - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: "PostToolUse", - tool_name: "Write", - tool_input: { file_path: path.join(repo, "src", "index.cjs"), content: "x" }, - }); - }); - assert.equal(callsForUnrelatedWrite, 0, "a path outside any task folder must reject before any git shellout"); - - const callsForAccepted = countGitInvocations(() => { - processPayload({ - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: "PostToolUse", - tool_name: "Write", - tool_input: { file_path: writeIntoTaskFolder(repo, "2026_08_15_alpha"), content: "x" }, - }); - }); - assert.ok(callsForAccepted > 0, "an accepted write must still resolve the repo root via git"); - }); - } finally { - cleanup(repo); - } -}); - -test("an unrecognised payload shells out to git zero times on tool-used, unlike session-start or turn-end - tool-used fires on every tool call and has no cheap pre-filter of its own", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/unrecognised-shellout-count.git" }); - try { - withEnv({ AIDD_RUNS_DIR: "" }, () => { - const unrecognised = { session_id: "x", cwd: repo }; - - const callsForToolUsed = countGitInvocations(() => { - processPayload({ ...unrecognised, hook_event_name: "PostToolUse" }, "tool-used"); - }); - assert.equal( - callsForToolUsed, - 0, - "tool-used must reject before any git shellout, or an unrecognised host's every call would pay one", - ); - - const callsForSessionStart = countGitInvocations(() => { - processPayload({ ...unrecognised, hook_event_name: "SessionStart" }, "session-start"); - }); - assert.ok(callsForSessionStart > 0, "session-start must still resolve the repo root via git"); - }); - } finally { - cleanup(repo); - } -}); - -test("turn-end's and file-written's in-process work stay under 200ms at p95 over 100 invocations each, against a directory holding several hundred run files", () => { - const harness = path.join(__dirname, "aidd-telemetry-journal-perf-harness.js"); - // Spawned so this spawnSync can enforce a real kill on a hang: node:test's - // own per-test timeout does not interrupt a blocking synchronous call. - const result = spawnSync(process.execPath, [harness], { - encoding: "utf8", - timeout: 60_000, - killSignal: "SIGKILL", - }); - - assert.equal(result.signal, null, `harness was killed (signal ${result.signal}) - in-process work hung`); - assert.equal(result.status, 0, `harness exited ${result.status}: ${result.stderr}`); - - const { p95, mean, max, n, seeded, fileWrittenReject, fileWrittenAccept } = JSON.parse(result.stdout); - assert.equal(n, 100); - assert.equal(seeded, 300); - assert.equal(fileWrittenReject.n, 100); - assert.equal(fileWrittenAccept.n, 100); - - console.log( - `journal file-written-reject latency: p95=${fileWrittenReject.p95.toFixed(3)}ms mean=${fileWrittenReject.mean.toFixed(3)}ms max=${fileWrittenReject.max.toFixed(3)}ms\n` + - `journal file-written-accept latency: p95=${fileWrittenAccept.p95.toFixed(3)}ms mean=${fileWrittenAccept.mean.toFixed(3)}ms max=${fileWrittenAccept.max.toFixed(3)}ms\n` + - `journal turn-end latency: p95=${p95.toFixed(3)}ms mean=${mean.toFixed(3)}ms max=${max.toFixed(3)}ms ` + - `(${n} invocations, ${seeded} run files already on disk)`, - ); - - assert.ok(p95 < 200, `turn-end p95 was ${p95.toFixed(3)}ms, budget is 200ms`); - assert.ok(fileWrittenReject.p95 < 200, `file-written-reject p95 was ${fileWrittenReject.p95.toFixed(3)}ms, budget is 200ms`); - assert.ok(fileWrittenAccept.p95 < 200, `file-written-accept p95 was ${fileWrittenAccept.p95.toFixed(3)}ms, budget is 200ms`); -}); - -// task_id must carry a leading yyyy_mm_ prefix for the month segment to be derivable. -function makeTaskFolder(repo, taskId) { - const month = taskId.slice(0, 7); - const dir = path.join(repo, "aidd_docs", "tasks", month, taskId); - fs.mkdirSync(dir, { recursive: true }); - return dir; -} - -function writeIntoTaskFolder(repo, taskId, filename = "notes.md") { - const dir = makeTaskFolder(repo, taskId); - const filePath = path.join(dir, filename); - fs.writeFileSync(filePath, "test\n"); - return filePath; -} - -// Mirrors scripts/__tests__/fixtures/claude-code-post-tool-use-*.json. -function fileWrittenPayload({ cwd, sessionId, filePath, toolName = "Write" }) { - return { - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd, - hook_event_name: "PostToolUse", - tool_name: toolName, - tool_input: toolName === "NotebookEdit" ? { notebook_path: filePath } : { file_path: filePath }, - }; -} - -test("looksLikeTaskPath is true for either task shape, false otherwise", () => { - assert.equal(looksLikeTaskPath("/repo/aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"), true); - assert.equal(looksLikeTaskPath("/repo/aidd_docs/tasks/2026_08/2026_08_15_alpha.md"), true); - assert.equal(looksLikeTaskPath("/repo/aidd_docs/tasks/2026_08/notes.txt"), false); - assert.equal(looksLikeTaskPath("/repo/src/index.js"), false); - assert.equal(looksLikeTaskPath(""), false); - assert.equal(looksLikeTaskPath(undefined), false); - assert.equal(looksLikeTaskPath(42), false); -}); - -test("looksLikeTaskPath recognises a Windows-shaped backslash path", () => { - assert.equal( - looksLikeTaskPath("C:\\repo\\aidd_docs\\tasks\\2026_08\\2026_08_15_alpha\\notes.md"), - true, - ); -}); - -test("taskFolderRelativePath returns the path relative to repoRoot when it resolves inside repoRoot's task folder", () => { - assert.equal( - taskFolderRelativePath("/repo", "/repo/aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"), - "aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md", - ); -}); - -test("taskFolderRelativePath returns null when the path is outside repoRoot entirely", () => { - assert.equal(taskFolderRelativePath("/repo", "/elsewhere/aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"), null); -}); - -test("taskFolderRelativePath returns null for a sibling directory that merely shares repoRoot as a string prefix", () => { - // repoRoot "/repo" must not match "/repoaidd_docs/..." - a bare startsWith - // without a "/" boundary would let it, and the remainder after slicing off - // the raw prefix would then satisfy the anchored pattern too. - assert.equal( - taskFolderRelativePath("/repo", "/repoaidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"), - null, - ); - assert.equal( - taskFolderRelativePath("/repo", "/repo-other/aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"), - null, - ); -}); - -test("taskFolderRelativePath returns null when the path is inside the repo but names no task", () => { - assert.equal(taskFolderRelativePath("/repo", "/repo/src/index.js"), null); - assert.equal(taskFolderRelativePath("/repo", "/repo/aidd_docs/tasks/2026_08/notes.txt"), null); -}); - -test("taskFolderRelativePath reads a task written as a single .md file", () => { - assert.equal( - taskFolderRelativePath("/repo", "/repo/aidd_docs/tasks/2026_08/2026_08_15_alpha.md"), - "aidd_docs/tasks/2026_08/2026_08_15_alpha.md", - ); -}); - -test("taskFolderRelativePath recognises a Windows-shaped backslash path, and returns a '/'-separated result", () => { - assert.equal( - taskFolderRelativePath("C:\\repo", "C:\\repo\\aidd_docs\\tasks\\2026_08\\2026_08_15_alpha\\notes.md"), - "aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md", - ); -}); - -test("taskFolderRelativePath returns null for non-string or empty input", () => { - assert.equal(taskFolderRelativePath("/repo", ""), null); - assert.equal(taskFolderRelativePath("/repo", undefined), null); - assert.equal(taskFolderRelativePath("", "/repo/aidd_docs/tasks/2026_08/alpha/x.md"), null); - assert.equal(taskFolderRelativePath(undefined, "/repo/aidd_docs/tasks/2026_08/alpha/x.md"), null); -}); - - -test("a session with no file-written at all produces only the session_start line, never a file_written line", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/no-write.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000t1"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const lines = readLines(written[0]); - assert.equal(lines.length, 1); - assert.equal(lines[0].type, "session_start"); - } finally { - cleanup(repo); - } -}); - -test("a session whose only write lands outside any task folder appends no file_written line", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/write-outside.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000t2"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const written = readRunFiles(runsDirOf(repo)); - const before = fs.readFileSync(written[0]); - - const filePath = path.join(repo, "src", "index.cjs"); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, "x\n"); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath })); - - const after = fs.readFileSync(written[0]); - assert.ok(after.equals(before), "a write outside any task folder must append nothing at all"); - } finally { - cleanup(repo); - } -}); - -test("a session appends a file_written line naming the path its first write lands in", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/first-write.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000t3"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const filePath = writeIntoTaskFolder(repo, "2026_08_15_alpha"); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath })); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines.length, 2); - assert.equal(lines[1].type, "file_written"); - assert.equal(lines[1].path, "aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"); - } finally { - cleanup(repo); - } -}); - -test("a second write into the same task folder appends a second file_written line, not a merged interval", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/same-task.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000t4"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const filePath = writeIntoTaskFolder(repo, "2026_08_15_alpha"); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath })); - - execFileSync("sleep", ["1.1"]); // cross a whole-second boundary, see the ten-turns test above - - replayIn( - fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_15_alpha", "more.md") }), - ); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines.length, 3, "one session_start line plus two separate file_written lines"); - assert.equal(lines[1].type, "file_written"); - assert.equal(lines[1].path, "aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"); - assert.equal(lines[2].type, "file_written"); - assert.equal(lines[2].path, "aidd_docs/tasks/2026_08/2026_08_15_alpha/more.md"); - assert.notEqual(lines[2].at, lines[1].at, "the second write is its own observation, not folded into the first"); - } finally { - cleanup(repo); - } -}); - -test("a session whose writes move from task A to task B produces two file_written lines, one path each, in order", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/task-switch.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000t5"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_15_alpha") })); - execFileSync("sleep", ["1.1"]); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_16_beta") })); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - - assert.equal(lines.length, 3); - assert.equal(lines[1].type, "file_written"); - assert.equal(lines[1].path, "aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"); - assert.equal(lines[2].type, "file_written"); - assert.equal(lines[2].path, "aidd_docs/tasks/2026_08/2026_08_16_beta/notes.md"); - } finally { - cleanup(repo); - } -}); - -test("turn-end never appends a file_written line - a write and a turn are always separate observations", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/turn-end-tasks.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000t6"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_15_alpha") })); - - const written = readRunFiles(runsDirOf(repo)); - const beforeLines = readLines(written[0]); - - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - - const afterLines = readLines(written[0]); - assert.equal(afterLines.length, beforeLines.length + 1); - assert.deepEqual(afterLines.slice(0, beforeLines.length), beforeLines, "every line already on disk is unchanged"); - assert.equal(afterLines[afterLines.length - 1].type, "turn_end"); - } finally { - cleanup(repo); - } -}); - -test("file-written's accept path appends a line with its own fresh `at` - the de-facto turn signal on a host with no turn-end event", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/file-written-ended-at.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000ea1"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const written = readRunFiles(runsDirOf(repo)); - const startLine = readLines(written[0])[0]; - - execFileSync("sleep", ["1.1"]); - - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_15_alpha") })); - - const lines = readLines(written[0]); - assert.equal(lines.length, 2); - assert.notEqual(lines[1].at, startLine.at); - } finally { - cleanup(repo); - } -}); - -test("file-written's reject path appends nothing at all - only the accept path is already paying for a write", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/file-written-reject-ended-at.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000ea2"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const written = readRunFiles(runsDirOf(repo)); - const before = fs.readFileSync(written[0]); - - execFileSync("sleep", ["1.1"]); - - replayIn({ - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: "PostToolUse", - tool_name: "Bash", - tool_input: { command: "echo hi" }, - }); - - const after = fs.readFileSync(written[0]); - assert.ok(after.equals(before)); - } finally { - cleanup(repo); - } -}); - -test("a NotebookEdit into a task folder appends a file_written line, reading tool_input.notebook_path rather than file_path", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/notebook-edit.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000nb1"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const notebookPath = writeIntoTaskFolder(repo, "2026_08_15_alpha", "scratch.ipynb"); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: notebookPath, toolName: "NotebookEdit" })); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines[1].type, "file_written"); - assert.equal(lines[1].path, "aidd_docs/tasks/2026_08/2026_08_15_alpha/scratch.ipynb"); - } finally { - cleanup(repo); - } -}); - -test("an Edit into a task folder appends a file_written line, same as Write", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/edit-attaches.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000ed1"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const filePath = writeIntoTaskFolder(repo, "2026_08_15_alpha"); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath, toolName: "Edit" })); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines[1].type, "file_written"); - assert.equal(lines[1].path, "aidd_docs/tasks/2026_08/2026_08_15_alpha/notes.md"); - } finally { - cleanup(repo); - } -}); - -test("a Bash call into what looks like a task path (via tool_input.command, not a write-target field) never appends a line", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/bash-not-a-write.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000bh1"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const taskDir = makeTaskFolder(repo, "2026_08_15_alpha"); - const result = replayIn({ - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: "PostToolUse", - tool_name: "Bash", - tool_input: { command: `cat ${path.join(taskDir, "notes.md")}`, description: "read a task file" }, - }); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - // No file_written: handleFileWritten's gate is unmoved by this change. The command text - // reading a real task file is exactly what a declaration exists to catch on a host with - // no write-path field at all, so it - and only it - joins session_start. - assert.equal(lines.filter((line) => line.type === "file_written").length, 0); - assert.deepEqual( - lines.map((line) => line.type), - ["session_start", "task_declared"], - ); - } finally { - cleanup(repo); - } -}); - -test("a Bash call whose tool_input happens to carry a file_path key still never appends a line - the gate reads tool_name, not field presence", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/bash-with-file-path.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000bh3"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - - const filePath = writeIntoTaskFolder(repo, "2026_08_15_alpha"); - const result = replayIn({ - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: "PostToolUse", - tool_name: "Bash", - tool_input: { file_path: filePath, command: "irrelevant" }, - }); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - // No file_written, for the same reason as above: tool_name gates it, not field presence. - // A declaration reads the same command text and finds the same real task path in it. - assert.equal(lines.filter((line) => line.type === "file_written").length, 0); - assert.deepEqual( - lines.map((line) => line.type), - ["session_start", "task_declared"], - ); - } finally { - cleanup(repo); - } -}); - -test("replaying the recorded Bash PostToolUse fixture against a real opted-in repo never appends a line, only the whitelisted tools do", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/bash-fixture.git" }); - try { - const fixture = loadFixture("claude-code-post-tool-use-bash.json"); - const sessionId = "00000000-0000-4000-8000-0000000000bh2"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn({ ...fixture, session_id: sessionId, cwd: repo }); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines.length, 1); - } finally { - cleanup(repo); - } -}); - -test("every file_written line carries exactly type, at, path, source - no fifth key", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/interval-whitelist.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000t7"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_15_alpha") })); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_16_beta") })); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - const fileWrittenLines = lines.filter((line) => line.type === "file_written"); - assert.equal(fileWrittenLines.length, 2); - for (const line of fileWrittenLines) { - assert.deepEqual(Object.keys(line).sort(), FILE_WRITTEN_KEYS); - assert.ok(["tool-stated", "observed"].includes(line.source), `unknown source ${line.source}`); - } - } finally { - cleanup(repo); - } -}); - -test("appending a later line never rewrites the bytes already on disk - each prior byte is byte-identical after every subsequent append", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/append-only.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000ap1"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const filePath = written[0]; - - const afterStart = fs.readFileSync(filePath); - - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_15_alpha") })); - const afterWrite = fs.readFileSync(filePath); - assert.ok(afterWrite.length > afterStart.length); - assert.ok( - afterWrite.subarray(0, afterStart.length).equals(afterStart), - "the session_start line's bytes must be unchanged after the file-written append", - ); - - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - const afterStop = fs.readFileSync(filePath); - assert.ok(afterStop.length > afterWrite.length); - assert.ok( - afterStop.subarray(0, afterWrite.length).equals(afterWrite), - "every byte written before the turn-end append must be unchanged after it", - ); - } finally { - cleanup(repo); - } -}); - -test("a truncated final line leaves every earlier line readable", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/truncated-last-line.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000000tr1"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: writeIntoTaskFolder(repo, "2026_08_15_alpha") })); - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - - const written = readRunFiles(runsDirOf(repo)); - const filePath = written[0]; - const complete = fs.readFileSync(filePath); - - // Cut into the last line's own bytes, not at a "\n" boundary - the file - // ends in "\n", so dropping a handful of trailing bytes lands mid-line. - const truncated = complete.subarray(0, complete.length - 5); - fs.writeFileSync(filePath, truncated); - - const rawLines = fs.readFileSync(filePath, "utf8").split("\n").filter((l) => l.length > 0); - assert.equal(rawLines.length, 3, "two complete lines plus the truncated remnant of the third"); - for (let i = 0; i < rawLines.length - 1; i++) { - assert.doesNotThrow(() => JSON.parse(rawLines[i]), `line ${i} must still parse after the last line was truncated`); - } - assert.throws(() => JSON.parse(rawLines[rawLines.length - 1]), "the truncated final line must not parse cleanly"); - } finally { - cleanup(repo); - } -}); - -test("no source file in hooks/lib/ reads a run file's contents back - record.cjs and file-writes.cjs never read a file at all", () => { - // Scoped to record.cjs and file-writes.cjs, not repo.cjs, which legitimately reads - // .aidd/config.json. The static half of "append never reads"; the dynamic half is the - // corrupted-content and byte-identity tests above. - const recordSrc = fs.readFileSync( - path.join(root, "plugins/aidd-telemetry/hooks/lib/record.cjs"), - "utf8", - ); - const fileWritesSrc = fs.readFileSync( - path.join(root, "plugins/aidd-telemetry/hooks/lib/file-writes.cjs"), - "utf8", - ); - // Every way of reading a file, not just the one in use today: a regression - // reintroducing read-modify-write through fs.readFile or a stream would - // otherwise slip past a guard that only knows one name. - const ANY_READ = /\b(readFileSync|readFile|createReadStream|openSync|promises\s*\.\s*readFile)\b/u; - assert.doesNotMatch(recordSrc, ANY_READ, "record.cjs must never read a run file back in order to append to it"); - assert.doesNotMatch(fileWritesSrc, ANY_READ, "file-writes.cjs must never read a run file back in order to append to it"); -}); - -// Runs the hook as a real, non-blocking child process so two sessions can -// genuinely overlap in wall-clock time. -function replayAsync(payload, event = ARGV_EVENT_BY_HOOK_EVENT_NAME[payload.hook_event_name]) { - return new Promise((resolve, reject) => { - const args = event ? [script, event] : [script]; - const child = spawn(process.execPath, args, { - cwd: root, - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: "" }, - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.on("error", reject); - child.on("close", (code) => resolve({ code, stderr })); - child.stdin.write(JSON.stringify(payload)); - child.stdin.end(); - }); -} - -test("two concurrent sessions in the same checkout each record only their own writes, never the other's", async () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/concurrent.git" }); - try { - const sessionA = "00000000-0000-4000-8000-0000000000c1"; - const sessionB = "00000000-0000-4000-8000-0000000000c2"; - - const [startA, startB] = await Promise.all([ - replayAsync(makePayload({ cwd: repo, sessionId: sessionA, event: "SessionStart" })), - replayAsync(makePayload({ cwd: repo, sessionId: sessionB, event: "SessionStart" })), - ]); - assert.equal(startA.code, 0); - assert.equal(startB.code, 0); - - const runsPath = runsDirOf(repo); - assert.equal(readRunFiles(runsPath).length, 2); - - const filePathA = writeIntoTaskFolder(repo, "2026_08_15_alpha", "a.md"); - const filePathB = writeIntoTaskFolder(repo, "2026_08_16_beta", "b.md"); - const [writeA, writeB] = await Promise.all([ - replayAsync(fileWrittenPayload({ cwd: repo, sessionId: sessionA, filePath: filePathA })), - replayAsync(fileWrittenPayload({ cwd: repo, sessionId: sessionB, filePath: filePathB })), - ]); - assert.equal(writeA.code, 0); - assert.equal(writeB.code, 0); - - const files = readRunFiles(runsPath); - assert.equal(files.length, 2); - - const byVendorId = {}; - for (const file of files) { - const lines = readLines(file); - byVendorId[lines[0].vendor_id] = lines; - } - assert.deepEqual(Object.keys(byVendorId).sort(), [sessionA, sessionB].sort()); - - const linesA = byVendorId[sessionA]; - assert.equal(linesA.length, 2); - assert.equal(linesA[1].type, "file_written"); - assert.equal(linesA[1].path, "aidd_docs/tasks/2026_08/2026_08_15_alpha/a.md"); - - const linesB = byVendorId[sessionB]; - assert.equal(linesB.length, 2); - assert.equal(linesB[1].type, "file_written"); - assert.equal(linesB[1].path, "aidd_docs/tasks/2026_08/2026_08_16_beta/b.md"); - } finally { - cleanup(repo); - } -}); - -// Read once so every test below fails together if a line is renamed or -// reordered, rather than drifting silently apart from what is committed. -function readAiddGitignoreBlock() { - // core.autocrlf checks this repository's own .gitignore out with CRLF on Windows; - // splitting on a bare "\n" would leave a trailing "\r" on every line. - const lines = fs.readFileSync(path.join(root, ".gitignore"), "utf8").split(/\r?\n/u); - const startIndex = lines.findIndex((line) => line.trim() === ".aidd/*"); - assert.ok(startIndex !== -1, "expected an .aidd/* line in the repository's own .gitignore"); - return lines.slice(startIndex, startIndex + 2); -} - -test("the repository's own .gitignore excludes .aidd/ state but tracks .aidd/config.json, the committed telemetry switch", () => { - assert.deepEqual(readAiddGitignoreBlock(), [".aidd/*", "!.aidd/config.json"]); -}); - -// Read once so every test below fails together if a line is renamed or -// reordered, rather than drifting silently apart from what is committed. -function readRunsGitignoreBlock() { - // Same CRLF-checkout reason as readAiddGitignoreBlock above. - const lines = fs.readFileSync(path.join(root, ".gitignore"), "utf8").split(/\r?\n/u); - const startIndex = lines.findIndex((line) => line.trim() === "aidd_docs/runs/*"); - assert.ok(startIndex !== -1, "expected an aidd_docs/runs/* line in the repository's own .gitignore"); - return lines.slice(startIndex, startIndex + 3); -} - -test("the repository's own .gitignore carries the documented three-line aidd_docs/runs/ block", () => { - assert.deepEqual(readRunsGitignoreBlock(), [ - "aidd_docs/runs/*", - "!aidd_docs/runs/.gitkeep", - "!aidd_docs/runs/README.md", - ]); -}); - -test("in a real temporary git repo: the marker files are tracked, a record file is not, and `git add -A` sweeps nothing in", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/runs-gitignore.git", withRunsDir: false }); - try { - const rules = readRunsGitignoreBlock(); - fs.writeFileSync(path.join(repo, ".gitignore"), `${rules.join("\n")}\n`); - - const runsPath = runsDirOf(repo); - fs.mkdirSync(runsPath, { recursive: true }); - fs.writeFileSync(path.join(runsPath, ".gitkeep"), ""); - fs.writeFileSync(path.join(runsPath, "README.md"), "marker\n"); - - execFileSync("git", ["add", "-A"], { cwd: repo, env: CLEAN_ENV }); - execFileSync("git", ["commit", "-q", "-m", "opt into the run journal"], { cwd: repo, env: CLEAN_ENV }); - - const tracked = execFileSync("git", ["ls-files", "aidd_docs/runs"], { cwd: repo, encoding: "utf8", env: CLEAN_ENV }) - .trim() - .split("\n") - .sort(); - assert.deepEqual(tracked, ["aidd_docs/runs/.gitkeep", "aidd_docs/runs/README.md"]); - - const sessionId = "00000000-0000-4000-8000-0000000000g1"; - const result = replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(result.status, 0); - - const recordFiles = fs.readdirSync(runsPath).filter((f) => f.endsWith(".jsonl")); - assert.equal(recordFiles.length, 1, "the record did not land in aidd_docs/runs/"); - const recordPath = path.join(runsPath, recordFiles[0]); - assert.ok(fs.existsSync(recordPath), "the record must be present on disk"); - - const checkIgnore = spawnSync("git", ["check-ignore", "-q", recordPath], { cwd: repo, env: CLEAN_ENV }); - assert.equal(checkIgnore.status, 0, "the record file must be recognised as git-ignored"); - - execFileSync("git", ["add", "-A"], { cwd: repo, env: CLEAN_ENV }); - const status = execFileSync("git", ["status", "--porcelain"], { cwd: repo, encoding: "utf8", env: CLEAN_ENV }); - assert.equal(status, "", "git add -A followed by status --porcelain must leave a clean tree"); - - assert.ok(fs.existsSync(recordPath), "the record must remain on disk after git add -A"); - } finally { - cleanup(repo); - } -}); - -test("a repository whose .gitignore excludes .aidd/* (config.json excepted) and aidd_docs/runs/* stays clean after a session writes into an already-tracked task file", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/gitignore-aidd.git" }); - // Reuses the exact rules from this repository's own .gitignore, so the - // integration proof and the documented rules cannot silently drift apart. - const aiddRules = readAiddGitignoreBlock(); - const runsRules = readRunsGitignoreBlock(); - - fs.writeFileSync(path.join(repo, ".gitignore"), `${aiddRules.join("\n")}\n${runsRules.join("\n")}\n`); - const filePath = writeIntoTaskFolder(repo, "2026_08_15_alpha"); - execFileSync("git", ["add", "-A"], { cwd: repo, env: CLEAN_ENV }); - execFileSync("git", ["commit", "-q", "-m", "add gitignore and task file"], { cwd: repo, env: CLEAN_ENV }); - - try { - const sessionId = "00000000-0000-4000-8000-0000000000t8"; - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath })); - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" })); - - const status = execFileSync("git", ["status", "--short"], { cwd: repo, encoding: "utf8", env: CLEAN_ENV }); - assert.equal(status, ""); - } finally { - cleanup(repo); - } -}); - -test("a credential in the remote never reaches the journal", () => { - const repo = makeTempRepo({ remote: "https://x-access-token:ghp_SECRET123@github.com/acme/private.git" }); - try { - replayIn( - makePayload({ cwd: repo, sessionId: "00000000-0000-4000-8000-0000000000cr", event: "SessionStart" }), - ); - const files = readRunFiles(runsDirOf(repo)); - assert.equal(files.length, 1); - const raw = fs.readFileSync(files[0], "utf8"); - assert.equal(raw.includes("ghp_SECRET123"), false, "the token must not appear anywhere in the file"); - assert.equal(raw.includes("x-access-token"), false); - - const line = JSON.parse(raw.split("\n")[0]); - assert.equal(line.project_remote, "https://github.com/acme/private.git"); - assert.equal(line.project_id, "acme/private"); - } finally { - cleanup(repo); - } -}); - -test("remoteWithoutCredentials strips userinfo from a scheme URL and leaves scp-style whole", () => { - const { remoteWithoutCredentials } = require("../../plugins/aidd-telemetry/hooks/lib/repo.cjs"); - assert.equal( - remoteWithoutCredentials("https://user:pass@github.com/o/r.git"), - "https://github.com/o/r.git", - ); - assert.equal( - remoteWithoutCredentials("https://ghp_x@github.com/o/r.git"), - "https://github.com/o/r.git", - ); - assert.equal(remoteWithoutCredentials("https://github.com/o/r.git"), "https://github.com/o/r.git"); - assert.equal(remoteWithoutCredentials("git@github.com:o/r.git"), "git@github.com:o/r.git"); - assert.equal(remoteWithoutCredentials(null), null); -}); - -test("a leaked GIT_DIR never redirects a session into another repository", () => { - const here = makeTempRepo({ remote: "git@github.com:acme/here.git" }); - const elsewhere = makeTempRepo({ remote: "git@github.com:acme/elsewhere.git" }); - try { - const result = replayInWithGitDir( - makePayload({ cwd: here, sessionId: "00000000-0000-4000-8000-0000000000gd", event: "SessionStart" }), - path.join(elsewhere, ".git"), - ); - - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(elsewhere)).length, 0); - - const written = readRunFiles(runsDirOf(here)); - assert.equal(written.length, 1); - assert.equal(readLines(written[0])[0].project_id, "acme/here"); - } finally { - cleanup(here); - cleanup(elsewhere); - } -}); - -// Each host's own SessionStart shape, mirroring fixtures/*-session-start.json: same field -// names, synthetic ids so each test owns its own session. - -function makeCodexPayload({ cwd, sessionId, event, turnId }) { - return { - session_id: sessionId, - turn_id: turnId, - transcript_path: `/home/user/probe/codex-home/sessions/2026/08/14/rollout-2026-08-14T10-11-20-${sessionId}.jsonl`, - cwd, - hook_event_name: event, - model: "probe-stub", - permission_mode: "bypassPermissions", - source: "startup", - }; -} - -function makeCopilotPayload({ cwd, sessionId }) { - // Copilot's canonical builder - never carries hook_event_name, on any event (see - // fixtures/README.md). The event name can only ever come from argv for this shape. - // The other shape Copilot can send, _vsCodeCompat, does carry hook_event_name - see - // makeCopilotCompatPayload below and fixtures/copilot-compat-*.json. - return { - sessionId, - timestamp: Date.now(), - cwd, - source: "new", - initialPrompt: "reply with the single word ok", - }; -} - -// Copilot's other builder, _vsCodeCompat: Claude Code's own event spelling reused verbatim, -// plus a timestamp field neither Codex nor Claude Code carries. Shaped from the untouched -// capture in fixtures/copilot-compat-*.json. -function makeCopilotCompatPayload({ cwd, sessionId, event }) { - return { - hook_event_name: event, - session_id: sessionId, - timestamp: new Date().toISOString(), - cwd, - }; -} - -// Cursor's captured payload carries no top-level cwd at all, only workspace_roots. This -// builder mirrors that shape exactly and must NOT grow a cwd field to make a happy-path test -// pass, or the test would assert a capability the code does not have. -function makeCursorPayload({ cwd, sessionId, event }) { - return { - conversation_id: sessionId, - generation_id: sessionId, - model: "default", - is_background_agent: false, - session_id: sessionId, - hook_event_name: event, - cursor_version: "2026.08.11-e8db854", - workspace_roots: [cwd], - user_email: "user@example.com", - transcript_path: null, - }; -} - -test("a Codex session-start writes a session_start line naming codex, vendor_field conversation.id - measured on codex.sse_event", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/codex-start.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cdx1"; - const result = replayIn(makeCodexPayload({ cwd: repo, sessionId, event: "SessionStart" })); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const line = readLines(written[0])[0]; - assert.deepEqual(Object.keys(line).sort(), SESSION_START_KEYS); - assert.equal(line.tool, "codex"); - assert.equal(line.vendor_id, sessionId); - assert.equal(line.vendor_field, "conversation.id"); - } finally { - cleanup(repo); - } -}); - -test("a Codex turn-end (Stop) appends a turn_end line to the same file - one host table, no dispatcher change", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/codex-turn-end.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cdx2"; - replayIn(makeCodexPayload({ cwd: repo, sessionId, event: "SessionStart" })); - - execFileSync("sleep", ["1.1"]); - - const result = replayIn(makeCodexPayload({ cwd: repo, sessionId, event: "Stop", turnId: "codex-turn-1" })); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines.length, 2); - assert.equal(lines[1].type, "turn_end"); - } finally { - cleanup(repo); - } -}); - -test("a Cursor session-start payload carrying no cwd still produces a run file - readCwd resolves workspace_roots (task 4), closing the gap phase 1 first shipped with", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/cursor-no-cwd.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cur1"; - const payload = makeCursorPayload({ cwd: repo, sessionId, event: "sessionStart" }); - assert.equal(payload.cwd, undefined, "this payload must carry no cwd - that is exactly the shape being proven"); - - const result = replayIn(payload); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const line = readLines(written[0])[0]; - assert.deepEqual(Object.keys(line).sort(), SESSION_START_KEYS); - assert.equal(line.tool, "cursor"); - assert.equal(line.vendor_id, sessionId); - assert.equal(line.vendor_field, null); - } finally { - cleanup(repo); - } -}); - -test("a Cursor workspace whose first root is not a git repository resolves to the root that is, not index zero", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/cursor-multi-root.git" }); - const notARepo = makeTempDir("aidd-telemetry-not-a-repo-"); - try { - const sessionId = "00000000-0000-4000-8000-0000000cur4"; - const payload = makeCursorPayload({ cwd: repo, sessionId, event: "sessionStart" }); - payload.workspace_roots = [notARepo, repo]; - - const result = replayIn(payload); - assert.equal(result.status, 0); - - assert.equal(readRunFiles(notARepo).length, 0, "the non-repository root must never be treated as a write target"); - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1, "the second root, the one that is actually a git repository, must be the one resolved"); - } finally { - cleanup(repo, notARepo); - } -}); - -test("readCwd: every host but Cursor reads payload.cwd directly; Cursor reads the first workspace_roots entry that is a git repository", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/read-cwd-unit.git" }); - const notARepo = makeTempDir("aidd-telemetry-not-a-repo-unit-"); - try { - assert.equal(readCwd("claude-code", { cwd: "/some/path" }), "/some/path"); - assert.equal(readCwd("codex", { cwd: "/some/path" }), "/some/path"); - assert.equal(readCwd("copilot", { cwd: "/some/path" }), "/some/path"); - - assert.equal(readCwd("cursor", { workspace_roots: [notARepo, repo] }), repo); - assert.equal(readCwd("cursor", { workspace_roots: [repo, notARepo] }), repo); - assert.equal(readCwd("cursor", { workspace_roots: [notARepo] }), undefined); - assert.equal(readCwd("cursor", { workspace_roots: [] }), undefined); - assert.equal(readCwd("cursor", {}), undefined); - assert.equal(readCwd("cursor", { cwd: repo }), undefined, "Cursor's reader must never fall back to cwd - it never carries one"); - } finally { - cleanup(repo, notARepo); - } -}); - -test("Cursor's per-host declaration is correct on its own terms: readSessionId reads session_id, and vendor_field is null because the export itself is unmeasured, not because Cursor went undetected", () => { - assert.equal( - readSessionId("cursor", { session_id: "cursor-declared-1" }), - "cursor-declared-1", - ); - assert.equal( - VENDOR_FIELD_BY_HOST.cursor, - null, - "Cursor's telemetry export is unmeasured (an Enterprise team setting) - null states that fact rather than guessing a documented-but-uncaptured attribute name", - ); -}); - -test("Copilot's readSessionId reads whichever shape the payload carries: sessionId for the canonical builder, session_id for _vsCodeCompat", () => { - assert.equal(readSessionId("copilot", { sessionId: "copilot-canonical-1" }), "copilot-canonical-1"); - assert.equal(readSessionId("copilot", { session_id: "copilot-compat-1" }), "copilot-compat-1"); -}); - -test("Cursor's real headless end-of-session shape (sessionEnd, captured under out-cursor-skill) resolves to no canonical event at all - stated as a fact about resolveEventName directly", () => { - assert.equal( - resolveEventName(undefined, { hook_event_name: "sessionEnd" }), - null, - "sessionEnd is not mapped to turn-end - substituting a fabricated turn boundary is exactly what task 5 forbids", - ); -}); - -test("a Copilot canonical session-start writes nothing when no event resolves - that shape never carries hook_event_name, so with no argv there is nothing to fall back to", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/copilot-blocked.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cop1"; - // No explicit event: mirrors the canonical shape's real traffic, where hook_event_name - // is absent (see fixtures/README.md). Copilot's other shape, _vsCodeCompat, does carry - // hook_event_name and is covered separately below. - const result = replayIn(makeCopilotPayload({ cwd: repo, sessionId }), undefined); - assert.equal(result.status, 0); - - assert.equal( - readRunFiles(runsDirOf(repo)).length, - 0, - "the canonical shape supplies no event name of its own, in payload or elsewhere - argv is its only source", - ); - } finally { - cleanup(repo); - } -}); - -test("a Copilot session-start, given a resolvable event, writes a session_start line carrying sessionId as vendor_id - proves the camelCase reader on its own, independent of the argv gap", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/copilot-camelcase.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cop2"; - const result = replayIn(makeCopilotPayload({ cwd: repo, sessionId }), "session-start"); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const line = readLines(written[0])[0]; - assert.equal(line.tool, "copilot"); - assert.equal(line.vendor_id, sessionId, "vendor_id must be the real id, not the string \"undefined\""); - assert.equal(line.vendor_field, "gen_ai.conversation.id"); - } finally { - cleanup(repo); - } -}); - -test("a Copilot session-start with an empty sessionId writes nothing, even given a resolvable event - the guard reads behind Copilot's own spelling too", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/copilot-empty-id.git" }); - try { - const result = replayIn({ sessionId: "", timestamp: Date.now(), cwd: repo, source: "new" }, "session-start"); - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("a Copilot compat session-start writes a session_start line carrying session_id as vendor_id - the other reader required, independent of the canonical sessionId spelling", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/copilot-compat-start.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cop3"; - const result = replayIn( - makeCopilotCompatPayload({ cwd: repo, sessionId, event: "SessionStart" }), - "session-start", - ); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const line = readLines(written[0])[0]; - assert.deepEqual(Object.keys(line).sort(), SESSION_START_KEYS); - assert.equal(line.tool, "copilot"); - assert.equal(line.vendor_id, sessionId, "vendor_id must be the real id, not the string \"undefined\""); - assert.equal(line.vendor_field, "gen_ai.conversation.id"); - } finally { - cleanup(repo); - } -}); - -test("a Copilot compat turn-end appends a turn_end line to the file its own session-start opened - one host table, both its shapes", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/copilot-compat-turn-end.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cop4"; - replayIn(makeCopilotCompatPayload({ cwd: repo, sessionId, event: "SessionStart" }), "session-start"); - - const result = replayIn( - makeCopilotCompatPayload({ cwd: repo, sessionId, event: "Stop" }), - "turn-end", - ); - assert.equal(result.status, 0); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines.length, 2); - assert.equal(lines[1].type, "turn_end"); - } finally { - cleanup(repo); - } -}); - -// A run file against none is not the decisive comparison; unrecognised payload against no -// payload at all is, and it is asserted separately below. -test("a Copilot compat session-start writes a run file; an unrecognised payload of the same event writes the unrecognised marker instead, never a run file of its own", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/copilot-compat-vs-unrecognised.git" }); - try { - const recognisedId = "00000000-0000-4000-8000-0000000cop5"; - const recognised = replayIn( - makeCopilotCompatPayload({ cwd: repo, sessionId: recognisedId, event: "SessionStart" }), - "session-start", - ); - assert.equal(recognised.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 1, "the recognised shape must produce a run file"); - - // Same event, same cwd, missing only the field the compat rule requires (timestamp) - - // a payload that arrived but matches no known host's shape. - const unrecognised = replayIn( - { session_id: "00000000-0000-4000-8000-0000000cop6", hook_event_name: "SessionStart", cwd: repo }, - "session-start", - ); - assert.equal(unrecognised.status, 0); - assert.equal( - readRunFiles(runsDirOf(repo)).filter((f) => path.basename(f) !== UNRECOGNISED_FILE_NAME).length, - 1, - "still one run file - the unrecognised payload must never mint a session file of its own", - ); - assert.equal( - fs.existsSync(path.join(runsDirOf(repo), UNRECOGNISED_FILE_NAME)), - true, - "the unrecognised payload must still leave its own trace", - ); - } finally { - cleanup(repo); - } -}); - -test("an unrecognised payload leaves a trace that no payload at all does not - the two stop being the same observable state", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/unrecognised-vs-silence.git" }); - const markerPath = path.join(runsDirOf(repo), UNRECOGNISED_FILE_NAME); - try { - const silent = replayEmpty("session-start"); - assert.equal(silent.status, 0); - assert.equal(fs.existsSync(markerPath), false, "no payload at all must leave no trace of any kind"); - - const unrecognised = replayIn( - { session_id: "x", transcript_path: "/home/user/somewhere/else/notes.txt", cwd: repo, hook_event_name: "SessionStart" }, - "session-start", - ); - assert.equal(unrecognised.status, 0); - assert.equal( - fs.existsSync(markerPath), - true, - "a payload that arrived and matched no host must leave a trace, distinguishing it from no payload at all", - ); - } finally { - cleanup(repo); - } -}); - -test("a payload matching no declared host's shape writes no run file, only the unrecognised marker, and exits 0, in a real switched-on repo", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/undeclared-host.git" }); - try { - const result = replayIn( - { session_id: "x", transcript_path: "/home/user/somewhere/else/notes.txt", cwd: repo, hook_event_name: "SessionStart" }, - "session-start", - ); - assert.equal(result.status, 0); - assert.deepEqual(readRunFiles(runsDirOf(repo)).map((f) => path.basename(f)), [UNRECOGNISED_FILE_NAME]); - } finally { - cleanup(repo); - } -}); - -test("a second unrecognised payload in the same repo writes no second line - the marker is bounded to one, not one per tool call", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/unrecognised-bounded.git" }); - try { - const payload = { session_id: "x", cwd: repo, hook_event_name: "SessionStart" }; - replayIn(payload, "session-start"); - replayIn(payload, "session-start"); - replayIn(payload, "session-start"); - - const markerPath = path.join(runsDirOf(repo), UNRECOGNISED_FILE_NAME); - assert.equal(readLines(markerPath).length, 1, "a whole session of unrecognised calls must still cost one line"); - } finally { - cleanup(repo); - } -}); - -test("an unrecognised payload refreshes the marker's `at` rather than freezing on the first occurrence - a diagnosis that never moved forward would be stale-forever, one file over", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/unrecognised-refreshed.git" }); - try { - const markerPath = path.join(runsDirOf(repo), UNRECOGNISED_FILE_NAME); - fs.mkdirSync(path.dirname(markerPath), { recursive: true }); - fs.writeFileSync( - markerPath, - `${JSON.stringify({ type: "unrecognised_payload", at: "2020-01-01T00:00:00Z" })}\n`, - ); - - const result = replayIn({ session_id: "x", cwd: repo, hook_event_name: "SessionStart" }, "session-start"); - assert.equal(result.status, 0); - - const lines = readLines(markerPath); - assert.equal(lines.length, 1, "still exactly one line - refreshed, not appended"); - assert.notEqual( - lines[0].at, - "2020-01-01T00:00:00Z", - "the marker must carry the moment of the most recent unrecognised payload, not the first", - ); - } finally { - cleanup(repo); - } -}); - -test("an unrecognised payload naming no directory of any kind - no cwd, no workspace_roots, nothing - still leaves the marker, falling back to the hook process's own cwd", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/unrecognised-no-cwd-key.git" }); - try { - // The payload's shape is by definition unknown: it may not spell its working - // directory `cwd` at all (Cursor, a declared host, already does not), so this proves - // the fallback rather than the convention every other test here happens to supply. - const result = replayInAt(repo, { totally: "unknown", shape: 1 }, "session-start"); - assert.equal(result.status, 0); - - assert.equal( - fs.existsSync(path.join(runsDirOf(repo), UNRECOGNISED_FILE_NAME)), - true, - "the marker must exist even though the payload named no directory of any kind", - ); - } finally { - cleanup(repo); - } -}); - -test("replaying codex-post-tool-use.json and cursor-post-tool-use.json exits 0 and writes only what was already there - captured ahead of the phase 2 extractors that will read them", () => { - for (const name of ["codex-post-tool-use.json", "cursor-post-tool-use.json"]) { - const result = replay(readFixture(name), "tool-used"); - assert.equal(result.status, 0, `${name} must exit 0`); - assert.equal(result.stdout, ""); - assert.equal(result.stderr, ""); - } -}); - -test("every fixture in the directory is free of a real email address, a real home path, or a token - not just the four checked before phase 1", () => { - const names = fs.readdirSync(fixturesDir).filter((name) => name.endsWith(".json")); - assert.ok(names.length >= 10, "expected at least the ten fixtures phase 1 leaves behind"); - for (const name of names) { - const raw = readFixture(name); - assert.doesNotMatch(raw, /baptistelafourcade/iu, `${name} leaks a real username`); - assert.doesNotMatch(raw, /\/Users\//u, `${name} leaks a real macOS home path`); - assert.doesNotMatch(raw, /\/private\/tmp\//u, `${name} leaks an unredacted probe scratchpad path`); - assert.doesNotMatch(raw, /@gmail\.com/iu, `${name} leaks a real email domain`); - assert.doesNotMatch(raw, /@ecomail\.fr/iu, `${name} leaks a real email domain`); - - const emails = raw.match(/"[^"]+@[^"]+"/gu) || []; - for (const quoted of emails) { - assert.equal(quoted, '"user@example.com"', `${name} carries an email address other than the redaction placeholder: ${quoted}`); - } - - const absolutePaths = raw.match(/"(\/[^"]*)"/gu) || []; - for (const quoted of absolutePaths) { - const value = quoted.slice(1, -1); - assert.ok( - value.startsWith("/home/user"), - `${name} has an absolute path not under /home/user: ${value}`, - ); - } - } -}); - - -const { - SKILL_FILE_PATTERN, - STEP_START_BY_HOST, -} = require("../../plugins/aidd-telemetry/hooks/lib/step-starts.cjs"); -const { buildStepStartLine } = require("../../plugins/aidd-telemetry/hooks/lib/record.cjs"); - -// Each entry is a real captured payload, edited only where a test needs its own repo, session -// or skill name: Copilot delivering its arguments as a JSON string and Codex naming the tool -// `Bash` are exactly the details an invented shape would get wrong. -const STEP_FIXTURE_BY_HOST = { - "claude-code": "claude-code-post-tool-use-skill.json", - copilot: "copilot-post-tool-use-skill.json", - codex: "codex-post-tool-use-skill-read.json", - cursor: "cursor-post-tool-use-skill-read.json", -}; - -// Codex's session identity is the rollout it writes, not the session_id the payload also -// carries, which on a resumed session names its parent. Renaming only session_id would point -// the two events at two different sessions. -function retargetCodexTranscript(payload, sessionId) { - // The last 36 characters before the extension, exactly as the hook's own parse takes - // them - matching a UUID-ish run of characters instead could cross the timestamp - // boundary, and this codebase already has two parses of this filename to keep in step. - const stem = payload.transcript_path.slice(0, -".jsonl".length); - payload.transcript_path = `${stem.slice(0, -36)}${sessionId}.jsonl`; -} - -function stepPayload(host, { cwd, sessionId, skill }) { - const payload = loadFixture(STEP_FIXTURE_BY_HOST[host]); - if (host === "copilot") { - payload.sessionId = sessionId; - payload.cwd = cwd; - if (skill) payload.toolArgs = JSON.stringify({ skill }); - return payload; - } - payload.session_id = sessionId; - if (host === "codex") retargetCodexTranscript(payload, sessionId); - if (host === "cursor") payload.workspace_roots = [cwd]; - else payload.cwd = cwd; - if (skill) rewriteSkillIn(payload, skill); - return payload; -} - -function rewriteSkillIn(payload, skill) { - if (payload.tool_input.skill !== undefined) { - payload.tool_input.skill = skill; - return; - } - for (const key of Object.keys(payload.tool_input)) { - const value = payload.tool_input[key]; - if (typeof value === "string") { - payload.tool_input[key] = value.replace(/skills\/[^/]+\/SKILL\.md/u, `skills/${skill}/SKILL.md`); - } - } -} - -function sessionStartPayload(host, { cwd, sessionId }) { - const payload = loadFixture(`${host}-session-start.json`); - if (host === "copilot") { - payload.sessionId = sessionId; - payload.cwd = cwd; - return payload; - } - payload.session_id = sessionId; - if (host === "codex") retargetCodexTranscript(payload, sessionId); - if (host === "cursor") payload.workspace_roots = [cwd]; - else payload.cwd = cwd; - return payload; -} - -// Built from the same captured Claude Code payload the step_start tests replay, with only -// the tool call swapped for the shell command a skill runs to declare its end - so the shape -// under test is one that host really sends, never one hand-written to pass. -function endPayload(cwd, sessionId, skill) { - const payload = loadFixture(STEP_FIXTURE_BY_HOST["claude-code"]); - payload.session_id = sessionId; - payload.cwd = cwd; - payload.tool_name = "Bash"; - payload.tool_input = { command: `echo "aidd:step-end ${skill}"` }; - return payload; -} - -// A skill's end is the one thing about a step no host emits: a `Skill` call's `tool_result` -// returns in a tenth of a second, which is the dispatch and not the completion. So the skill -// declares it, and the hook, which alone holds the session id and cwd, writes the line. -test("a skill declaring its own end leaves a step_end naming it", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-end.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000e0de"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - const result = replayIn(endPayload(repo, sessionId, "aidd-dev:01-plan"), "tool-used"); - assert.equal(result.status, 0); - - const ends = readRunFiles(runsDirOf(repo)) - .flatMap((file) => readLines(file)) - .filter((line) => line.type === "step_end"); - assert.equal(ends.length, 1); - assert.equal(ends[0].skill, "aidd-dev:01-plan"); - assert.ok(typeof ends[0].at === "string" && ends[0].at.length > 0); - } finally { - cleanup(repo); - } -}); - -// The watermark `task-declared.cjs` protects: file-writes.cjs reads the run file's mtime as -// "the moment this session last wrote a line", so a step end landing between a shell write -// and the turn end that observes it would push that mark past the write. -test("a step end never costs the turn a write it should have observed", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-end-mtime.git" }); - const sessionId = "00000000-0000-4000-8000-00000000e0df"; - try { - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - writeIntoTaskFolder(repo, "2026_08_21_ended"); - // Between the write and the turn end that observes it - the exact placement that costs - // the write if this line moves the watermark past it. - replayIn(endPayload(repo, sessionId, "aidd-dev:01-plan"), "tool-used"); - replayIn({ ...makePayload({ cwd: repo, sessionId }), hook_event_name: "Stop" }, "turn-end"); - - const lines = readLines(readRunFiles(runsDirOf(repo))[0]); - assert.equal(lines.filter((line) => line.type === "step_end").length, 1); - assert.deepEqual( - lines.filter((line) => line.type === "file_written").map((line) => line.source), - ["observed"] - ); - } finally { - cleanup(repo); - } -}); - -function stepLinesIn(repo) { - const written = readRunFiles(runsDirOf(repo)); - if (written.length === 0) return []; - return readLines(written[0]).filter((line) => line.type === "step_start"); -} - -// Claude Code and Copilot name the skill in a tool argument; Codex and Cursor leave only a -// SKILL.md path. One assertion for four hosts, because the caller cannot tell which family -// ran. Hex throughout, so Codex's identity is really derived from its transcript path rather -// than falling back to session_id because a synthetic id is not a UUID. -const STEP_SESSION_SUFFIX_BY_HOST = { - "claude-code": "aaa", - copilot: "bbb", - codex: "ccc", - cursor: "ddd", -}; - -for (const host of Object.keys(STEP_FIXTURE_BY_HOST)) { - test(`a skill opened on ${host} leaves a step_start naming it, from a payload that host actually sent`, () => { - const repo = makeTempRepo({ remote: `git@github.com:acme/step-${host}.git` }); - try { - const sessionId = `00000000-0000-4000-8000-000000000${STEP_SESSION_SUFFIX_BY_HOST[host]}`; - replayIn(sessionStartPayload(host, { cwd: repo, sessionId }), "session-start"); - const result = replayIn(stepPayload(host, { cwd: repo, sessionId }), "tool-used"); - assert.equal(result.status, 0); - - const steps = stepLinesIn(repo); - assert.equal(steps.length, 1); - assert.equal(steps[0].skill, "probe-echo"); - } finally { - cleanup(repo); - } - }); -} - -test("a skill opened on Copilot's compat payload shape leaves a step_start naming it - the second of Copilot's two shapes, captured separately from the canonical one", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-copilot-compat-skill.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cop7"; - replayIn(makeCopilotCompatPayload({ cwd: repo, sessionId, event: "SessionStart" }), "session-start"); - - const payload = loadFixture("copilot-compat-post-tool-use-skill.json"); - payload.session_id = sessionId; - payload.cwd = repo; - const result = replayIn(payload, "tool-used"); - assert.equal(result.status, 0); - - const steps = stepLinesIn(repo); - assert.equal(steps.length, 1); - assert.equal(steps[0].skill, "00-init"); - } finally { - cleanup(repo); - } -}); - -test("a Bash call on Copilot's compat shape opens no step - only a skill call does, on either of Copilot's two shapes", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-copilot-compat-bash.git" }); - try { - const sessionId = "00000000-0000-4000-8000-0000000cop8"; - replayIn(makeCopilotCompatPayload({ cwd: repo, sessionId, event: "SessionStart" }), "session-start"); - - const payload = loadFixture("copilot-compat-post-tool-use.json"); - payload.session_id = sessionId; - payload.cwd = repo; - const result = replayIn(payload, "tool-used"); - assert.equal(result.status, 0); - - assert.deepEqual(stepLinesIn(repo), []); - } finally { - cleanup(repo); - } -}); - -test("two skills interleaved leave three ordered lines and two distinct names - the sticky attribution this whole ticket exists to replace", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-interleaved.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000steA"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - for (const skill of ["alpha", "beta", "alpha"]) { - replayIn(stepPayload("claude-code", { cwd: repo, sessionId, skill }), "tool-used"); - } - - const steps = stepLinesIn(repo); - assert.deepEqual( - steps.map((line) => line.skill), - ["alpha", "beta", "alpha"] - ); - assert.equal(new Set(steps.map((line) => line.skill)).size, 2); - } finally { - cleanup(repo); - } -}); - -test("where the host delivers a turn identifier the step carries it, so the join to cost is exact rather than ordinal", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-turn-id.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000steB"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - const payload = stepPayload("claude-code", { cwd: repo, sessionId }); - payload.prompt_id = "16231051-346b-4bfd-addc-581f911ef878"; - replayIn(payload, "tool-used"); - - const [step] = stepLinesIn(repo); - assert.equal(step.turn_id, "16231051-346b-4bfd-addc-581f911ef878"); - } finally { - cleanup(repo); - } -}); - -test("a host that carries no turn identifier omits the key rather than writing it null", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-no-turn-id.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000steC"; - replayIn(sessionStartPayload("copilot", { cwd: repo, sessionId }), "session-start"); - replayIn(stepPayload("copilot", { cwd: repo, sessionId }), "tool-used"); - - const [step] = stepLinesIn(repo); - assert.equal(Object.hasOwn(step, "turn_id"), false); - } finally { - cleanup(repo); - } -}); - -test("a step line carries no end, no duration and no parent - none of the three is anything a tool said", () => { - const line = buildStepStartLine({ at: "2026-08-20T10:00:00Z", skill: "alpha", turnId: "t1" }); - assert.deepEqual(Object.keys(line).sort(), ["at", "skill", "turn_id", "type"]); -}); - -test("a skill name carrying separators or traversal cannot escape its own field", () => { - // The separators are what make traversal traversal; the dots alone name nothing. - assert.equal(buildStepStartLine({ at: "x", skill: "../../etc/passwd" }).skill, "..-..-etc-passwd"); - assert.equal(buildStepStartLine({ at: "x", skill: ".." }).skill, "-"); - assert.equal(buildStepStartLine({ at: "x", skill: "a/b" }).skill, "a-b"); -}); - -test("an ordinary tool call opens no step", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-ordinary-tool.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000steD"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - const filePath = writeIntoTaskFolder(repo, "2026_08_20_ordinary"); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath }), "tool-used"); - - assert.equal(stepLinesIn(repo).length, 0); - } finally { - cleanup(repo); - } -}); - -test("a skill call opens a step even though it has no task-folder path - the two readings of the event share nothing but the event", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-no-task-path.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000steE"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - replayIn(stepPayload("claude-code", { cwd: repo, sessionId }), "tool-used"); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - assert.equal(lines.filter((line) => line.type === "step_start").length, 1); - assert.equal(lines.filter((line) => line.type === "file_written").length, 0); - } finally { - cleanup(repo); - } -}); - -test("a file whose name ends in SKILL.md but sits outside a skills tree opens nothing", () => { - assert.equal(SKILL_FILE_PATTERN.test("/repo/docs/SKILL.md"), false); - assert.equal(SKILL_FILE_PATTERN.test("/repo/notskills/alpha/SKILL.md"), false); - assert.equal(SKILL_FILE_PATTERN.test("/repo/.cursor/skills/alpha/SKILL.md"), true); - assert.equal(SKILL_FILE_PATTERN.test("sed -n '1,120p' .agents/skills/alpha/SKILL.md"), true); -}); - -// The decisive shape is a call the argument family rejects that still carries a SKILL.md -// path: a fallback chain would mint a phantom step here, and a payload the argument family -// accepts could never show it, since the first family would answer and the second never run. -test("reading a SKILL.md on an argument-family host opens no step - the table names one family, it is not a fallback chain", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-two-candidates.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000steF"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - - const payload = stepPayload("claude-code", { cwd: repo, sessionId }); - payload.tool_name = "Read"; - payload.tool_input = { file_path: `${repo}/.claude/skills/other/SKILL.md` }; - const result = replayIn(payload, "tool-used"); - assert.equal(result.status, 0); - - assert.deepEqual(stepLinesIn(repo), []); - } finally { - cleanup(repo); - } -}); - -test("a skill argument still opens its step when the same payload also carries an unrelated SKILL.md path", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-decoy-path.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000steI"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - const payload = stepPayload("claude-code", { cwd: repo, sessionId }); - payload.tool_input.decoy = "/repo/.claude/skills/other/SKILL.md"; - replayIn(payload, "tool-used"); - - const steps = stepLinesIn(repo); - assert.equal(steps.length, 1); - assert.equal(steps[0].skill, "probe-echo"); - } finally { - cleanup(repo); - } -}); - -test("a step for a session that was never journaled writes nothing and exits 0", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-no-run-file.git" }); - try { - const result = replayIn( - stepPayload("claude-code", { cwd: repo, sessionId: "00000000-0000-4000-8000-00000000steG" }), - "tool-used" - ); - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("a step still open at session end reads differently from one closed by a turn boundary", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/step-open-vs-closed.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000steH"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - replayIn(stepPayload("claude-code", { cwd: repo, sessionId, skill: "closed" }), "tool-used"); - replayIn(makePayload({ cwd: repo, sessionId, event: "Stop" }), "turn-end"); - replayIn(stepPayload("claude-code", { cwd: repo, sessionId, skill: "open" }), "tool-used"); - - const written = readRunFiles(runsDirOf(repo)); - const lines = readLines(written[0]); - const lastStep = lines.map((line) => line.type).lastIndexOf("step_start"); - const lastTurnEnd = lines.map((line) => line.type).lastIndexOf("turn_end"); - assert.equal(lines[lastStep].skill, "open"); - assert.ok(lastStep > lastTurnEnd, "the trailing step has no turn boundary after it"); - } finally { - cleanup(repo); - } -}); - -test("adding a fifth host is a table entry, not an edit to the handler", () => { - assert.deepEqual(Object.keys(STEP_START_BY_HOST).sort(), [ - "claude-code", - "codex", - "copilot", - "cursor", - ]); - for (const declaration of Object.values(STEP_START_BY_HOST)) { - assert.equal(typeof declaration.skillName, "function"); - } -}); - -// The word hooks.json ships and the word journal.cjs accepts are two halves of one contract, -// and nothing else checks they agree. A mismatch leaves the journal dead on every real -// installation while a suite running from the source tree stays green. -test("every argv word hooks.json ships is one journal.cjs recognises", () => { - const declared = JSON.parse( - fs.readFileSync(path.join(root, "plugins/aidd-telemetry/hooks/hooks.json"), "utf8") - ); - const words = []; - for (const groups of Object.values(declared.hooks)) { - for (const group of groups) { - for (const entry of group.hooks) { - const word = entry.command.trim().split(/\s+/u).pop(); - words.push(word); - } - } - } - assert.ok(words.length > 0, "hooks.json declares at least one command"); - for (const word of words) { - assert.equal( - resolveEventName(word, {}), - word, - `journal.cjs does not recognise the argv word "${word}" that hooks.json ships` - ); - } -}); - -test("a file written through a shell command still reaches its task, observed at turn end", () => { - // The gap a live Claude Code session exposed: asked to create a file, the model reached - // for Bash, whose payload carries a command and no path. Parsing the command would be - // guessing; watching the task tree is observing. - const repo = makeTempRepo({ remote: "git@github.com:acme/observed-write.git" }); - const sessionId = "00000000-0000-4000-8000-00000000obs1"; - try { - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - const written = writeIntoTaskFolder(repo, "2026_08_21_shell"); - // A Bash call naming nothing, exactly as the host reports one. - replayIn({ - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd: repo, - hook_event_name: "PostToolUse", - tool_name: "Bash", - tool_input: { command: `printf x > ${written}`, description: "write" }, - }); - - const duringTheTurn = readLines(readRunFiles(runsDirOf(repo))[0]).filter( - (line) => line.type === "file_written", - ); - assert.equal(duringTheTurn.length, 0, "nothing is claimed while the turn is still running"); - - replayIn({ ...makePayload({ cwd: repo, sessionId }), hook_event_name: "Stop" }, "turn-end"); - - const observed = readLines(readRunFiles(runsDirOf(repo))[0]).filter( - (line) => line.type === "file_written", - ); - assert.equal(observed.length, 1, "the shell write is recorded at turn end"); - assert.match(observed[0].path, /aidd_docs\/tasks\/[^/]+\/2026_08_21_shell\//u); - assert.equal(observed[0].source, "observed", "and it says it was observed, not stated"); - } finally { - cleanup(repo); - } -}); - -test("a path the host stated is recorded as stated, and never twice", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/stated-write.git" }); - const sessionId = "00000000-0000-4000-8000-00000000obs2"; - try { - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - const written = writeIntoTaskFolder(repo, "2026_08_21_stated"); - replayIn(fileWrittenPayload({ cwd: repo, sessionId, filePath: written })); - replayIn({ ...makePayload({ cwd: repo, sessionId }), hook_event_name: "Stop" }, "turn-end"); - - const lines = readLines(readRunFiles(runsDirOf(repo))[0]).filter( - (line) => line.type === "file_written", - ); - // Appending the stated line moved the run file's mtime past the write, so the observed - // pass finds nothing to add. One write, one line. - assert.deepEqual( - lines.map((line) => line.source), - ["tool-stated"], - ); - } finally { - cleanup(repo); - } -}); - -test("a turn that wrote nothing into a task folder records nothing", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/no-write.git" }); - const sessionId = "00000000-0000-4000-8000-00000000obs3"; - try { - replayIn(makePayload({ cwd: repo, sessionId, event: "SessionStart" })); - replayIn({ ...makePayload({ cwd: repo, sessionId }), hook_event_name: "Stop" }, "turn-end"); - - const lines = readLines(readRunFiles(runsDirOf(repo))[0]); - assert.equal(lines.filter((line) => line.type === "file_written").length, 0); - } finally { - cleanup(repo); - } -}); - - -const { - TASK_PATH_PATTERN, - declaredTaskPath, -} = require("../../plugins/aidd-telemetry/hooks/lib/task-declared.cjs"); -const { buildTaskDeclaredLine } = require("../../plugins/aidd-telemetry/hooks/lib/record.cjs"); - -const TASK_RELATIVE_PATH = "aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md"; - -function taskLinesIn(repo) { - const written = readRunFiles(runsDirOf(repo)); - if (written.length === 0) return []; - return readLines(written[0]).filter((line) => line.type === "task_declared"); -} - -const TASK_DECLARED_FIXTURE_BY_HOST = { - "claude-code": "claude-code-task-declared.json", - cursor: "cursor-task-declared.json", - codex: "codex-task-declared.json", - copilot: "copilot-task-declared.json", -}; - -// Each entry is a real captured payload, edited only where a test needs its own repo or -// session. declaredTaskPath matches by pattern, not by cwd, so the captured arguments are -// left exactly as captured and only the fields the run-file lookup depends on are retargeted. -function taskPayload(host, { cwd, sessionId }) { - const payload = loadFixture(TASK_DECLARED_FIXTURE_BY_HOST[host]); - if (host === "cursor") { - payload.session_id = sessionId; - payload.conversation_id = sessionId; - payload.generation_id = sessionId; - payload.workspace_roots = [cwd]; - return payload; - } - payload.session_id = sessionId; - payload.cwd = cwd; - if (host === "codex") retargetCodexTranscript(payload, sessionId); - return payload; -} - -const DECLARE_SESSION_SUFFIX_BY_HOST = { - "claude-code": "dec1", - cursor: "dec2", - codex: "dec3", - copilot: "dec4", -}; - -test("TASK_PATH_PATTERN matches a folder task and a single-file task, and stops at a quote or space", () => { - assert.equal(TASK_PATH_PATTERN.test(TASK_RELATIVE_PATH), true); - assert.equal(TASK_PATH_PATTERN.test("aidd_docs/tasks/2026_08/2026_08_15_alpha.md"), true); - assert.equal(TASK_PATH_PATTERN.test("aidd_docs/tasks/2026_08/notes.txt"), false); - assert.equal( - TASK_PATH_PATTERN.exec(`"${TASK_RELATIVE_PATH}" more text`)[0], - TASK_RELATIVE_PATH, - ); -}); - -for (const host of Object.keys(DECLARE_SESSION_SUFFIX_BY_HOST)) { - test(`reading a task file on ${host} leaves a task_declared line naming its path - the tool's own arguments, never a field the host has to cooperate on`, () => { - const repo = makeTempRepo({ remote: `git@github.com:acme/declare-${host}.git` }); - try { - const sessionId = `00000000-0000-4000-8000-000000000${DECLARE_SESSION_SUFFIX_BY_HOST[host]}`; - replayIn(sessionStartPayload(host, { cwd: repo, sessionId }), "session-start"); - const result = replayIn(taskPayload(host, { cwd: repo, sessionId }), "tool-used"); - assert.equal(result.status, 0); - - const declared = taskLinesIn(repo); - assert.equal(declared.length, 1); - assert.equal(declared[0].path, TASK_RELATIVE_PATH); - } finally { - cleanup(repo); - } - }); -} - -test("a Skill call that names no task path declares nothing - opening a step is not declaring a task", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/declare-skill-only.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000dec5"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - replayIn(stepPayload("claude-code", { cwd: repo, sessionId }), "tool-used"); - - assert.deepEqual(taskLinesIn(repo), []); - } finally { - cleanup(repo); - } -}); - -test("an ordinary write outside any task folder declares nothing", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/declare-ordinary.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000dec6"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - replayIn( - fileWrittenPayload({ cwd: repo, sessionId, filePath: `${repo}/src/index.js` }), - "tool-used", - ); - - assert.deepEqual(taskLinesIn(repo), []); - } finally { - cleanup(repo); - } -}); - -test("reading two different task files across two calls leaves two lines - the writer never dedupes, a reader collapsing them is its own job", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/declare-two-calls.git" }); - try { - const sessionId = "00000000-0000-4000-8000-00000000dec7"; - replayIn(sessionStartPayload("claude-code", { cwd: repo, sessionId }), "session-start"); - replayIn(taskPayload("claude-code", { cwd: repo, sessionId }), "tool-used"); - const second = taskPayload("claude-code", { cwd: repo, sessionId }); - second.tool_input.file_path = second.tool_input.file_path.replace( - TASK_RELATIVE_PATH, - "aidd_docs/tasks/2026_08/2026_08_16_beta/spec.md", - ); - replayIn(second, "tool-used"); - - const declared = taskLinesIn(repo); - assert.deepEqual( - declared.map((line) => line.path), - [TASK_RELATIVE_PATH, "aidd_docs/tasks/2026_08/2026_08_16_beta/spec.md"], - ); - } finally { - cleanup(repo); - } -}); - -test("a declaration for a session that was never journaled writes nothing and exits 0", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/declare-no-run-file.git" }); - try { - const result = replayIn( - taskPayload("claude-code", { cwd: repo, sessionId: "00000000-0000-4000-8000-00000000dec8" }), - "tool-used", - ); - assert.equal(result.status, 0); - assert.equal(readRunFiles(runsDirOf(repo)).length, 0); - } finally { - cleanup(repo); - } -}); - -test("a task_declared line carries a path and nothing else task-shaped - no end, no source, no task_id", () => { - const line = buildTaskDeclaredLine({ at: "2026-08-20T10:00:00Z", path: TASK_RELATIVE_PATH }); - assert.deepEqual(Object.keys(line).sort(), ["at", "path", "type"]); - assert.equal(line.type, "task_declared"); -}); - -test("declaredTaskPath reads tool_input first and Copilot's toolArgs string only when tool_input is absent", () => { - assert.equal( - declaredTaskPath({ tool_input: { file_path: `/repo/${TASK_RELATIVE_PATH}` } }), - TASK_RELATIVE_PATH, - ); - assert.equal( - declaredTaskPath({ toolArgs: JSON.stringify({ path: `/repo/${TASK_RELATIVE_PATH}` }) }), - TASK_RELATIVE_PATH, - ); - assert.equal(declaredTaskPath({ tool_input: { command: "echo hi" } }), null); -}); - - -// A linked worktree of `repo`, switched on, with its own runs directory. The seed commit -// is what `git worktree add` needs a HEAD for; makeTempRepo leaves the repo empty. -function makeWorktree(repo, name) { - execFileSync("git", ["commit", "-q", "--allow-empty", "-m", "seed"], { cwd: repo, env: CLEAN_ENV }); - const worktree = path.join(path.dirname(repo), `${path.basename(repo)}-${name}`); - execFileSync("git", ["worktree", "add", "--quiet", "-b", name, worktree], { - cwd: repo, - env: CLEAN_ENV, - }); - fs.mkdirSync(runsDirOf(worktree), { recursive: true }); - writeTelemetryConfig(worktree, { enabled: true }); - return worktree; -} - -function sessionStartLineIn(dir) { - const [file] = readRunFiles(runsDirOf(dir)); - assert.ok(file, `no run file was written under ${runsDirOf(dir)}`); - return readLines(file)[0]; -} - -test("a session in a linked worktree names the worktree it ran in and the repository those worktrees share", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-named.git" }); - const worktree = makeWorktree(repo, "alpha"); - try { - withEnv({ AIDD_RUNS_DIR: "" }, () => { - processPayload( - makePayload({ cwd: worktree, sessionId: "wt-named-session", event: "SessionStart" }), - ); - }); - - const line = sessionStartLineIn(worktree); - assert.equal(line.worktree_id, path.basename(worktree)); - assert.equal(line.worktree_repo_id, path.basename(repo)); - } finally { - cleanup(repo, worktree); - } -}); - -test("two sessions from two worktrees of one repository are distinguishable in the journal, and still say they belong together", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-two.git" }); - const first = makeWorktree(repo, "alpha"); - const second = makeWorktree(repo, "beta"); - try { - withEnv({ AIDD_RUNS_DIR: "" }, () => { - processPayload(makePayload({ cwd: first, sessionId: "wt-alpha", event: "SessionStart" })); - processPayload(makePayload({ cwd: second, sessionId: "wt-beta", event: "SessionStart" })); - }); - - const alpha = sessionStartLineIn(first); - const beta = sessionStartLineIn(second); - assert.notEqual(alpha.worktree_id, beta.worktree_id); - // Both worktrees carry the same repository. Without this field they would not: with no - // remote, project_id falls back to each worktree's own directory name. - assert.equal(alpha.worktree_repo_id, beta.worktree_repo_id); - } finally { - cleanup(repo, first, second); - } -}); - -test("a session in a plain checkout carries no worktree field at all - not null, not an empty string, absent", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-absent.git" }); - try { - withEnv({ AIDD_RUNS_DIR: "" }, () => { - processPayload( - makePayload({ cwd: repo, sessionId: "plain-checkout-session", event: "SessionStart" }), - ); - }); - - const line = sessionStartLineIn(repo); - // An empty string would gather every plain checkout into one group as though they were - // the same worktree; the exact key set is what proves neither key is there at all. - assert.deepEqual(Object.keys(line).sort(), SESSION_START_KEYS); - assert.equal("worktree_id" in line, false); - assert.equal("worktree_repo_id" in line, false); - } finally { - cleanup(repo); - } -}); - -test("naming the worktree costs a plain checkout no extra git call - the two words are read off the rev-parse it already paid for", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/worktree-shellout.git" }); - const worktree = makeWorktree(repo, "alpha"); - try { - withEnv({ AIDD_RUNS_DIR: "" }, () => { - const callsForPlain = countGitInvocations(() => { - processPayload(makePayload({ cwd: repo, sessionId: "count-plain", event: "SessionStart" })); - }); - const callsForWorktree = countGitInvocations(() => { - processPayload( - makePayload({ cwd: worktree, sessionId: "count-worktree", event: "SessionStart" }), - ); - }); - - // Two, and the same two either way: one `rev-parse` resolving the root and the two - // worktree directories together, then one `remote get-url` for the project fields. - assert.equal(callsForPlain, 2); - assert.equal(callsForWorktree, callsForPlain); - }); - } finally { - cleanup(repo, worktree); - } -}); - -test("a plain checkout that happens to live in a directory named 'worktrees' is still a plain checkout", () => { - // The layout `/worktrees/` is what identifies a linked worktree, so a - // repository sitting at `.../worktrees` would pass that test on its own and be recorded - // as a worktree named ".git". - const parent = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-telemetry-wt-trap-")); - const repo = path.join(parent, "worktrees"); - try { - fs.mkdirSync(repo); - execFileSync("git", ["init", "-q"], { cwd: repo, env: CLEAN_ENV }); - fs.mkdirSync(runsDirOf(repo), { recursive: true }); - writeTelemetryConfig(repo, { enabled: true }); - - withEnv({ AIDD_RUNS_DIR: "" }, () => { - processPayload( - makePayload({ cwd: repo, sessionId: "worktrees-named-repo", event: "SessionStart" }), - ); - }); - - assert.deepEqual(Object.keys(sessionStartLineIn(repo)).sort(), SESSION_START_KEYS); - } finally { - cleanup(parent); - } -}); - -// OpenCode is the one host with no hook payload of its own: `opencode-plugin.js` builds one -// and spawns this same journal, so `lib/tools/opencode.cjs` is only ever reached through that -// synthesized shape. The payload below is exactly what the plugin builds, and it carries no -// `hook_event_name`, so the event can only come from argv. -function openCodePayload({ cwd, sessionId, toolInput }) { - return { - tool: "opencode", - session_id: sessionId, - cwd, - ...(toolInput === undefined ? {} : { tool_input: toolInput }), - }; -} - -test("an OpenCode session journals through the same hook, from the payload its own plugin builds", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/opencode-start.git" }); - try { - const sessionId = "ses_0fedcba98765"; - - const result = replayInAt(repo, openCodePayload({ cwd: repo, sessionId }), "session-start"); - - assert.equal(result.status, 0); - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const [line] = readLines(written[0]); - assert.equal(line.type, "session_start"); - assert.equal(line.tool, "opencode"); - // Its own `readSessionId` reads `session_id`; a change to that field would leave every - // OpenCode record joining to nothing while the journal still looked healthy. - assert.equal(line.vendor_id, sessionId); - assert.equal(line.project_id, "acme/opencode-start"); - } finally { - cleanup(repo); - } -}); - -test("an OpenCode turn_end appends to the file its session_start opened, never a second one", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/opencode-turn.git" }); - try { - const sessionId = "ses_0abcdef12345"; - const payload = openCodePayload({ cwd: repo, sessionId }); - assert.equal(replayInAt(repo, payload, "session-start").status, 0); - - assert.equal(replayInAt(repo, payload, "turn-end").status, 0); - - const written = readRunFiles(runsDirOf(repo)); - assert.equal(written.length, 1); - const lines = readLines(written[0]); - assert.deepEqual( - lines.map((entry) => entry.type), - ["session_start", "turn_end"] - ); - } finally { - cleanup(repo); - } -}); - -// `opencode.cjs` declares `stepStart: null` — OpenCode names no skill on any route — so a -// tool call must journal nothing beyond what the session already opened. Asserted rather -// than assumed: the plugin does forward tool calls, and the limit is only real if the hook -// actually declines them. -test("an OpenCode tool call opens no step, because that host names no skill", () => { - const repo = makeTempRepo({ remote: "git@github.com:acme/opencode-step.git" }); - try { - const sessionId = "ses_0aaaabbbbccc"; - assert.equal( - replayInAt(repo, openCodePayload({ cwd: repo, sessionId }), "session-start").status, - 0 - ); - - const toolInput = { file_path: `${repo}/plugins/x/skills/01-sdlc/SKILL.md` }; - assert.equal( - replayInAt(repo, openCodePayload({ cwd: repo, sessionId, toolInput }), "tool-used").status, - 0 - ); - - const lines = readLines(readRunFiles(runsDirOf(repo))[0]); - assert.equal( - lines.some((entry) => entry.type === "step_start"), - false - ); - } finally { - cleanup(repo); - } -}); diff --git a/scripts/__tests__/aidd-telemetry-manifest.test.js b/scripts/__tests__/aidd-telemetry-manifest.test.js deleted file mode 100644 index 2bdd3cda1..000000000 --- a/scripts/__tests__/aidd-telemetry-manifest.test.js +++ /dev/null @@ -1,19 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const test = require("node:test"); - -const manifestPath = path.resolve( - __dirname, - "../../plugins/aidd-telemetry/.claude-plugin/plugin.json", -); - -const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - -test("aidd-telemetry manifest declares its name", () => { - assert.equal(manifest.name, "aidd-telemetry"); -}); - -test("aidd-telemetry manifest declares no skills", () => { - assert.ok(!("skills" in manifest), "manifest must not declare a skills array"); -}); diff --git a/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js b/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js deleted file mode 100644 index c20951d39..000000000 --- a/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js +++ /dev/null @@ -1,345 +0,0 @@ -// Real captures of `session.idle` and a completed tool part, plus `session.created` -// reconstructed from a verified SDK type declaration and a genuinely captured sibling event - -// see fixtures/README.md for exactly what each one rests on and what it does not. -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { pathToFileURL } = require("node:url"); -const test = require("node:test"); - -const { detectHost } = require("../../plugins/aidd-telemetry/hooks/lib/host.cjs"); - -const PLUGIN_SOURCE = path.resolve( - __dirname, - "../../plugins/aidd-telemetry/hooks/opencode-plugin.js" -); -const fixturesDir = path.join(__dirname, "fixtures"); - -function loadFixture(name) { - return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8")); -} - -// A byte-identical `.mjs` twin: this repository declares no `"type": "module"` anywhere up -// the tree, so plain Node's `import()` would read the plugin as CommonJS and choke on its -// `export` syntax. OpenCode's own loader consults no such field, and the extension is the -// only thing that differs from what ships. -let pluginModulePromise; -let pluginTempDir; -test.after(() => { - if (pluginTempDir) fs.rmSync(pluginTempDir, { recursive: true, force: true }); -}); -async function pluginModule() { - if (!pluginModulePromise) { - pluginTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-opencode-payloads-")); - fs.mkdirSync(path.join(pluginTempDir, "plugin")); - fs.mkdirSync(path.join(pluginTempDir, "hooks")); - fs.copyFileSync( - path.resolve(__dirname, "../../cli/assets/configs/opencode/opencode-events.js.txt"), - path.join(pluginTempDir, "hooks", "opencode-events.js") - ); - const twin = path.join(pluginTempDir, "plugin", "opencode-plugin.mjs"); - fs.copyFileSync(PLUGIN_SOURCE, twin); - pluginModulePromise = import(pathToFileURL(twin).href); - } - return pluginModulePromise; -} - -async function journalCallFor() { - return (await pluginModule()).AiddTelemetry.journalCallFor; -} - -async function journalCallsFor() { - return (await pluginModule()).AiddTelemetry.journalCallsFor; -} - -// OpenCode loads every function-valued named export of a file in `plugin/` as a plugin -// factory of its own, and a second such export returning `null` kills `opencode run` before -// any session starts. A non-function export is ignored by that same loader, which is why the -// spawn-free seam rides on the plugin function as a property. -test("the default definition selects its one V1 factory and exposes V2 setup", async () => { - const exported = await pluginModule(); - const factories = Object.keys(exported).filter((name) => typeof exported[name] === "function"); - assert.deepEqual(factories, ["AiddTelemetry"]); - assert.equal(exported.default.id, "aidd-telemetry"); - assert.equal(exported.default.server, exported.AiddTelemetry); - assert.equal(typeof exported.default.setup, "function"); -}); - -// `opencode run` is always a session OpenCode never announced: `session.created` is published -// on its own bus and never reaches a plugin's event hook. Without these four cases the journal -// receives a turn-end for a run file that was never created, drops it, and every OpenCode -// session reads back as nothing at all while the tool is declared covered. -test("session.idle for a session nobody announced opens it first, so the journal has a run file to write into", async () => { - const calls = await journalCallsFor(); - const idle = loadFixture("opencode-session-idle.json"); - - const produced = calls(idle, new Map(), "/home/user/fallback"); - - assert.deepEqual( - produced.map((call) => call.script), - ["session-start", "turn-end"] - ); - assert.deepEqual( - produced.map((call) => call.payload.cwd), - ["/home/user/fallback", "/home/user/fallback"] - ); - assert.equal(detectHost(produced[0].payload), "opencode"); -}); - -test("a task declaration in a session nobody announced opens it first too, never arriving before its own run file", async () => { - const calls = await journalCallsFor(); - const part = loadFixture("opencode-tool-part-completed.json"); - - const produced = calls(part, new Map(), "/home/user/probe/project-opencode-task"); - - assert.deepEqual( - produced.map((call) => call.script), - ["session-start", "tool-used"] - ); - assert.equal(produced[0].payload.session_id, produced[1].payload.session_id); -}); - -test("a session already announced is never opened a second time", async () => { - const calls = await journalCallsFor(); - const sessionDirectories = new Map(); - - calls(loadFixture("opencode-session-created.json"), sessionDirectories, "/home/user/fallback"); - const produced = calls( - loadFixture("opencode-session-idle.json"), - sessionDirectories, - "/home/user/fallback" - ); - - assert.deepEqual( - produced.map((call) => call.script), - ["turn-end"] - ); -}); - -test("a session OpenCode did announce produces its one session-start, never a doubled one", async () => { - const calls = await journalCallsFor(); - - const produced = calls( - loadFixture("opencode-session-created.json"), - new Map(), - "/home/user/fallback" - ); - - assert.deepEqual( - produced.map((call) => call.script), - ["session-start"] - ); -}); - -test("a second session.idle on one already-opened session adds no second session-start", async () => { - const calls = await journalCallsFor(); - const idle = loadFixture("opencode-session-idle.json"); - const sessionDirectories = new Map(); - - calls(idle, sessionDirectories, "/home/user/fallback"); - const produced = calls(idle, sessionDirectories, "/home/user/fallback"); - - assert.deepEqual( - produced.map((call) => call.script), - ["turn-end"] - ); -}); - -test("an event this plugin does not act on opens no session either", async () => { - const calls = await journalCallsFor(); - - assert.deepEqual(calls({ type: "message.updated", properties: {} }, new Map(), "/x"), []); -}); - -// OpenCode's bus carries event types this plugin never dispatches on, none of them guaranteed -// to carry a `properties` object. Session id resolution runs before the per-type dispatch that -// would ignore such an event, so it alone decides whether one crashes OpenCode's event loop. -test("an event with no properties at all does not crash resolving its session id", async () => { - const calls = await journalCallsFor(); - - assert.deepEqual(calls({ type: "server.connected" }, new Map(), "/x"), []); -}); - -// The field this reads is real, but nothing guarantees OpenCode fills `properties.info` -// before firing: an empty `properties` object must resolve to an undefined id, not throw. -test("session.created with an empty properties object does not crash, it opens an unnamed session", async () => { - const calls = await journalCallsFor(); - - const produced = calls({ type: "session.created", properties: {} }, new Map(), "/x"); - - assert.deepEqual( - produced.map((call) => call.script), - ["session-start"] - ); -}); - -test("session.idle, captured live, turns into a turn-end call the journal recognises as opencode", async () => { - const builder = await journalCallFor(); - const event = loadFixture("opencode-session-idle.json"); - - const call = builder(event, new Map(), "/home/user/fallback"); - - assert.deepEqual(call, { - script: "turn-end", - payload: { - tool: "opencode", - session_id: "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", - cwd: "/home/user/fallback", - }, - }); - assert.equal(detectHost(call.payload), "opencode"); -}); - -test("session.created turns into a session-start call the journal recognises as opencode", async () => { - const builder = await journalCallFor(); - const event = loadFixture("opencode-session-created.json"); - - const call = builder(event, new Map(), "/home/user/fallback"); - - assert.deepEqual(call, { - script: "session-start", - payload: { - tool: "opencode", - session_id: "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", - cwd: "/home/user/probe/project", - }, - }); - assert.equal(detectHost(call.payload), "opencode"); -}); - -test("the directory session.created names survives to session.idle's own turn-end call, since session.idle cannot supply one", async () => { - const builder = await journalCallFor(); - const created = loadFixture("opencode-session-created.json"); - const idle = loadFixture("opencode-session-idle.json"); - const sessionDirectories = new Map(); - - builder(created, sessionDirectories, "/home/user/fallback"); - const turnEnd = builder(idle, sessionDirectories, "/home/user/fallback"); - - assert.equal(turnEnd.payload.cwd, created.properties.info.directory); - assert.notEqual(turnEnd.payload.cwd, "/home/user/fallback"); -}); - -test("session.idle falls back to the plugin's own init-time directory when no session.created ever named one", async () => { - const builder = await journalCallFor(); - const idle = loadFixture("opencode-session-idle.json"); - - const turnEnd = builder(idle, new Map(), "/home/user/fallback"); - - assert.equal(turnEnd.payload.cwd, "/home/user/fallback"); -}); - -test("a second session.created on one server keeps its own directory, never overwriting the first", async () => { - const builder = await journalCallFor(); - const first = loadFixture("opencode-session-created.json"); - const second = { - type: "session.created", - properties: { - info: { - ...first.properties.info, - id: "ses_bbbbbbbbbbbbbbbbbbbbbbbbbb", - directory: "/home/user/probe/other-project", - }, - }, - }; - const sessionDirectories = new Map(); - - const firstCall = builder(first, sessionDirectories, "/home/user/fallback"); - const secondCall = builder(second, sessionDirectories, "/home/user/fallback"); - const firstIdle = builder( - { type: "session.idle", properties: { sessionID: first.properties.info.id } }, - sessionDirectories, - "/home/user/fallback" - ); - const secondIdle = builder( - { type: "session.idle", properties: { sessionID: second.properties.info.id } }, - sessionDirectories, - "/home/user/fallback" - ); - - assert.equal(firstCall.payload.cwd, first.properties.info.directory); - assert.equal(secondCall.payload.cwd, second.properties.info.directory); - assert.equal(firstIdle.payload.cwd, first.properties.info.directory); - assert.equal(secondIdle.payload.cwd, second.properties.info.directory); -}); - -test("an event this plugin does not act on produces no journal call", async () => { - const builder = await journalCallFor(); - - assert.equal(builder({ type: "message.updated", properties: {} }, new Map(), "/x"), null); -}); - -// A completed tool part, captured live: the model called its own `read` tool and the plugin's -// `event` hook received the part carrying that call's arguments. Turns into a tool-used call -// the same shape Claude Code's Read and Codex's Bash already give the declaration reader. -test("a completed tool part, captured live, turns into a tool-used call the journal recognises as opencode", async () => { - const builder = await journalCallFor(); - const event = loadFixture("opencode-tool-part-completed.json"); - - const call = builder(event, new Map(), "/home/user/probe/project-opencode-task"); - - assert.deepEqual(call, { - script: "tool-used", - payload: { - tool: "opencode", - session_id: "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", - cwd: "/home/user/probe/project-opencode-task", - tool_input: { - filePath: - "/home/user/probe/project-opencode-task/aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md", - }, - }, - }); - assert.equal(detectHost(call.payload), "opencode"); -}); - -test("a tool part still pending or running produces no journal call - only a completed one carries settled arguments", async () => { - const builder = await journalCallFor(); - const completed = loadFixture("opencode-tool-part-completed.json"); - const pending = { - ...completed, - properties: { - ...completed.properties, - part: { ...completed.properties.part, state: { status: "pending", input: {}, raw: "" } }, - }, - }; - const running = { - ...completed, - properties: { - ...completed.properties, - part: { - ...completed.properties.part, - state: { ...completed.properties.part.state, status: "running" }, - }, - }, - }; - - assert.equal(builder(pending, new Map(), "/x"), null); - assert.equal(builder(running, new Map(), "/x"), null); -}); - -// The negative, captured live in the same run as the positive above: a completed `bash` call -// naming no task folder anywhere in its arguments. The pre-filter refuses it before a call is -// ever built, and on OpenCode `tool-used` does nothing else downstream for such a call. -test("a completed tool part naming no task folder produces no journal call at all - nothing downstream would have acted on it", async () => { - const builder = await journalCallFor(); - const event = loadFixture("opencode-tool-part-no-task.json"); - - const call = builder(event, new Map(), "/home/user/probe/project-opencode-task"); - - assert.equal(call, null); -}); - -test("a non-tool part update (text, reasoning, patch) produces no journal call", async () => { - const builder = await journalCallFor(); - const event = { - type: "message.part.updated", - properties: { - sessionID: "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", - part: { type: "text", text: "hello" }, - }, - }; - - assert.equal(builder(event, new Map(), "/x"), null); -}); diff --git a/scripts/__tests__/aidd-telemetry-plugin-version.test.js b/scripts/__tests__/aidd-telemetry-plugin-version.test.js deleted file mode 100644 index 567384d62..000000000 --- a/scripts/__tests__/aidd-telemetry-plugin-version.test.js +++ /dev/null @@ -1,316 +0,0 @@ -const assert = require("node:assert/strict"); -const { execFileSync, spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -// Under a git hook, git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE, which would point -// a child git call here at the real repository instead of the temporary one. -const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")), -); - -const { - readManifestVersion, - versionFromAiddManifest, - MANIFEST_DIRS, - PLUGIN_NAME, - AIDD_TOOL_ID_BY_HOST, -} = require("../../plugins/aidd-telemetry/hooks/lib/plugin-version.cjs"); - -const REAL_MANIFEST_PATH = path.resolve( - __dirname, - "../../plugins/aidd-telemetry/.claude-plugin/plugin.json", -); -const REAL_PLUGIN_VERSION = JSON.parse(fs.readFileSync(REAL_MANIFEST_PATH, "utf8")).version; - -const tempDirs = []; -test.after(() => { - for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); -}); - -function makeTempDir(prefix) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -test("MANIFEST_DIRS names every directory the build renames this plugin's manifest into", () => { - // Duplicated from the CLI on purpose - this plugin is copied verbatim into user projects - // and can import nothing from `cli/` - and pinned here so the copy cannot drift. Read off - // each profile's own declared manifest path, since no single file lists them. - const profilesDir = path.resolve(__dirname, "../../cli/src/contexts/tools/domain/profiles"); - const declared = []; - for (const tool of fs.readdirSync(profilesDir, { withFileTypes: true })) { - if (!tool.isDirectory()) continue; - for (const file of fs.readdirSync(path.join(profilesDir, tool.name))) { - if (!file.endsWith(".ts")) continue; - const source = fs.readFileSync(path.join(profilesDir, tool.name, file), "utf8"); - for (const m of source.matchAll(/"(\.[A-Za-z0-9_.-]+)\/plugin\.json"/gu)) declared.push(m[1]); - } - } - - assert.ok(declared.length > 0, "the CLI must still declare manifest directories"); - assert.deepEqual([...MANIFEST_DIRS].sort(), [...new Set(declared)].sort()); -}); - -test("PLUGIN_NAME is the name this plugin's own manifest states", () => { - assert.equal(PLUGIN_NAME, JSON.parse(fs.readFileSync(REAL_MANIFEST_PATH, "utf8")).name); -}); - -test("AIDD_TOOL_ID_BY_HOST maps every journal host the CLI declares, onto that tool's own id", () => { - // The journal's host names and `.aidd/manifest.json`'s tool ids are the same set spelled - // twice; only Claude Code differs. A host missing here answers `undefined` and silently - // costs the version, so the map has to be complete rather than merely correct. - const profilesDir = path.resolve(__dirname, "../../cli/src/contexts/tools/domain/profiles"); - const declared = {}; - for (const tool of fs.readdirSync(profilesDir, { withFileTypes: true })) { - if (!tool.isDirectory()) continue; - const profile = path.join(profilesDir, tool.name, "profile.ts"); - if (!fs.existsSync(profile)) continue; - const host = /telemetryJournalHost:\s*"([^"]+)"/u.exec(fs.readFileSync(profile, "utf8")); - if (host) declared[host[1]] = tool.name; - } - - assert.ok(Object.keys(declared).length > 0, "the CLI must still declare journal hosts"); - assert.deepEqual(Object.keys(AIDD_TOOL_ID_BY_HOST).sort(), Object.keys(declared).sort()); -}); - -test("readManifestVersion reads the real plugin's own declared version", () => { - assert.equal(readManifestVersion(REAL_MANIFEST_PATH), REAL_PLUGIN_VERSION); - assert.notEqual(REAL_PLUGIN_VERSION, undefined, "the fixture itself must carry a real version"); -}); - -test("readManifestVersion never throws for a manifest that does not exist - it costs the version, not the caller", () => { - const missing = path.join(makeTempDir("aidd-plugin-version-missing-"), "does-not-exist.json"); - assert.doesNotThrow(() => readManifestVersion(missing)); - assert.equal(readManifestVersion(missing), null); -}); - -test("readManifestVersion reads null for a manifest that is not valid JSON", () => { - const dir = makeTempDir("aidd-plugin-version-invalid-json-"); - const manifestPath = path.join(dir, "plugin.json"); - fs.writeFileSync(manifestPath, "{ this is not json"); - - assert.equal(readManifestVersion(manifestPath), null); -}); - -test("readManifestVersion reads null for valid JSON that names no usable version", () => { - const dir = makeTempDir("aidd-plugin-version-no-field-"); - for (const [name, body] of [ - ["no-version-at-all.json", { name: "aidd-telemetry" }], - ["version-not-a-string.json", { name: "aidd-telemetry", version: 2 }], - ["version-empty-string.json", { name: "aidd-telemetry", version: "" }], - ]) { - const manifestPath = path.join(dir, name); - fs.writeFileSync(manifestPath, JSON.stringify(body)); - assert.equal(readManifestVersion(manifestPath), null, `${name} must read as no version`); - } -}); - -test("pluginVersion answers per repository, never handing one project's version to the next", () => { - // No process-wide memo: the second route makes the answer depend on which repository asks, - // so a cache keyed on nothing would hand one repository's answer to the next. - const repoWithout = makeTempDir("aidd-plugin-version-norepo-"); - const repoWith = makeTempDir("aidd-plugin-version-withrepo-"); - fs.mkdirSync(path.join(repoWith, ".aidd"), { recursive: true }); - fs.writeFileSync( - path.join(repoWith, ".aidd", "manifest.json"), - JSON.stringify({ tools: { cursor: { plugins: [{ name: PLUGIN_NAME, version: "9.9.9" }] } } }), - ); - - assert.equal(versionFromAiddManifest(repoWith, "cursor"), "9.9.9"); - assert.equal(versionFromAiddManifest(repoWithout, "cursor"), null); - assert.equal(versionFromAiddManifest(repoWith, "cursor"), "9.9.9"); -}); - -test("the aidd manifest answers for the host's own tool, never for whichever lists this plugin first", () => { - // `aidd plugin update --tool cursor` updates one tool's copy alone, so two entries can - // legitimately disagree. Answering with the wrong one would be worse than answering with - // nothing. - const repo = makeTempDir("aidd-plugin-version-twotools-"); - fs.mkdirSync(path.join(repo, ".aidd"), { recursive: true }); - fs.writeFileSync( - path.join(repo, ".aidd", "manifest.json"), - JSON.stringify({ - tools: { - claude: { plugins: [{ name: PLUGIN_NAME, version: "1.0.0" }] }, - cursor: { plugins: [{ name: PLUGIN_NAME, version: "2.0.0" }] }, - }, - }), - ); - - assert.equal(versionFromAiddManifest(repo, "claude-code"), "1.0.0"); - assert.equal(versionFromAiddManifest(repo, "cursor"), "2.0.0"); - assert.equal(versionFromAiddManifest(repo, "a-host-no-tool-claims"), null); -}); - -// The integration half: journal.cjs run from a temporary copy of this plugin's own hooks/ -// tree, so a missing manifest is exercised without touching this repository's own committed -// plugin.json - a structural concern neither the real tree nor a fixture alone can vary. -const HOOKS_SRC = path.resolve(__dirname, "../../plugins/aidd-telemetry/hooks"); -const REAL_CLAUDE_PLUGIN_DIR = path.resolve(__dirname, "../../plugins/aidd-telemetry/.claude-plugin"); - -/** A copy of `hooks/` plus `.claude-plugin/`, in the same relative layout a real install - * carries them in - `hooks/lib/plugin-version.cjs`'s own `DEFAULT_MANIFEST_PATH` walks up - * from `__dirname`, so this is what lets the copy's own manifest (present, corrupted, or - * missing entirely, per `withManifest`) be the one it actually reads. */ -function makePluginCopy(withManifest, manifestDir = ".claude-plugin") { - const pluginRoot = makeTempDir("aidd-plugin-version-copy-"); - fs.cpSync(HOOKS_SRC, path.join(pluginRoot, "hooks"), { recursive: true }); - if (withManifest === "valid") { - fs.cpSync(REAL_CLAUDE_PLUGIN_DIR, path.join(pluginRoot, manifestDir), { recursive: true }); - } else if (withManifest === "corrupt") { - fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); - fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), "{ not json"); - } - // withManifest === "absent": no .claude-plugin directory at all - the same shape a flat - // per-tool translation delivers, which carries hooks/ with nothing of the plugin's own - // manifest beside it (see opencode-plugin.test.js's makeInstalledRepo). - return path.join(pluginRoot, "hooks", "journal.cjs"); -} - -function makeTempRepo() { - const dir = makeTempDir("aidd-plugin-version-repo-"); - execFileSync("git", ["init", "-q"], { cwd: dir, env: CLEAN_ENV }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir, env: CLEAN_ENV }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: dir, env: CLEAN_ENV }); - fs.mkdirSync(path.join(dir, "aidd_docs", "runs"), { recursive: true }); - fs.mkdirSync(path.join(dir, ".aidd"), { recursive: true }); - fs.writeFileSync( - path.join(dir, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }), - ); - return dir; -} - -function sessionStartPayload(cwd, sessionId) { - return { - session_id: sessionId, - transcript_path: `/home/user/probe/cc-home/projects/-home-user-probe-project/${sessionId}.jsonl`, - cwd, - hook_event_name: "SessionStart", - source: "startup", - }; -} - -function readRunFileLines(repo) { - const runsDir = path.join(repo, "aidd_docs", "runs"); - const [fileName] = fs.readdirSync(runsDir).filter((entry) => entry.endsWith(".jsonl")); - assert.ok(fileName, "a session_start run file must be written regardless of the manifest"); - return fs - .readFileSync(path.join(runsDir, fileName), "utf8") - .trim() - .split("\n") - .map((line) => JSON.parse(line)); -} - -function runJournal(journalScript, repo, sessionId) { - return spawnSync(process.execPath, [journalScript, "session-start"], { - cwd: repo, - encoding: "utf8", - input: JSON.stringify(sessionStartPayload(repo, sessionId)), - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: "" }, - }); -} - -test("a plugin copy with its own real manifest beside it stamps the exact same version this process reads", () => { - const journalScript = makePluginCopy("valid"); - const repo = makeTempRepo(); - - const result = runJournal(journalScript, repo, "00000000-0000-4000-8000-0000000pv01"); - - assert.equal(result.status, 0); - const [sessionStart] = readRunFileLines(repo); - assert.equal(sessionStart.type, "session_start"); - assert.equal(sessionStart.plugin_version, REAL_PLUGIN_VERSION); -}); - -test("a plugin copy with no manifest at all still writes a complete session_start line, plugin_version simply absent", () => { - const journalScript = makePluginCopy("absent"); - const repo = makeTempRepo(); - - const result = runJournal(journalScript, repo, "00000000-0000-4000-8000-0000000pv02"); - - assert.equal(result.status, 0, "the hook must never fail because a version is unavailable"); - const [sessionStart] = readRunFileLines(repo); - assert.equal(sessionStart.type, "session_start"); - assert.equal(Object.prototype.hasOwnProperty.call(sessionStart, "plugin_version"), false); - // Every other documented key is still there - the missing version costs one field, never - // the line, and never any of its other facts. - for (const key of ["schema_version", "run_id", "tool", "vendor_id", "vendor_field"]) { - assert.ok( - Object.prototype.hasOwnProperty.call(sessionStart, key), - `"${key}" must still be present when the manifest cannot be read`, - ); - } -}); - -test("a plugin copy whose manifest is present but not valid JSON reads the same as no manifest at all", () => { - const journalScript = makePluginCopy("corrupt"); - const repo = makeTempRepo(); - - const result = runJournal(journalScript, repo, "00000000-0000-4000-8000-0000000pv03"); - - assert.equal(result.status, 0); - const [sessionStart] = readRunFileLines(repo); - assert.equal(Object.prototype.hasOwnProperty.call(sessionStart, "plugin_version"), false); -}); - -test("finds the manifest under every name the build renames it to, not only Claude's", () => { - // A lookup naming one manifest directory leaves every other tool writing a line with no - // version at all, indistinguishable from one written before the field existed. Driven - // through the real hook, one built layout at a time. - for (const manifestDir of MANIFEST_DIRS) { - const journalScript = makePluginCopy("valid", manifestDir); - const repo = makeTempRepo(); - - const result = runJournal(journalScript, repo, `00000000-0000-4000-8000-00000000pv${MANIFEST_DIRS.indexOf(manifestDir)}0`); - - assert.equal(result.status, 0, `${manifestDir}: ${result.stderr}`); - const [sessionStart] = readRunFileLines(repo); - assert.equal(sessionStart.plugin_version, REAL_PLUGIN_VERSION, `under ${manifestDir}`); - } -}); - -test("falls back to what the aidd CLI recorded when the hooks were installed away from any manifest", () => { - // `aidd setup` copies `hooks/` alone, with no manifest at any offset, and writes - // `.aidd/manifest.json` in the same act - the only thing left that knows the version. - const journalScript = makePluginCopy("absent"); - const repo = makeTempRepo(); - fs.writeFileSync( - path.join(repo, ".aidd", "manifest.json"), - JSON.stringify({ - version: 6, - tools: { claude: { plugins: [{ name: PLUGIN_NAME, version: "3.1.4" }] } }, - }), - ); - - const result = runJournal(journalScript, repo, "00000000-0000-4000-8000-0000000pv90"); - - assert.equal(result.status, 0, result.stderr); - const [sessionStart] = readRunFileLines(repo); - assert.equal(sessionStart.plugin_version, "3.1.4"); -}); - -test("prefers the plugin's own manifest over what a stale aidd manifest remembers", () => { - // The manifest beside the hooks is what this build *is*; the aidd manifest is what some - // earlier install recorded. When both answer, the plugin's own is the one that cannot be - // out of date with the file being run. - const journalScript = makePluginCopy("valid"); - const repo = makeTempRepo(); - fs.writeFileSync( - path.join(repo, ".aidd", "manifest.json"), - JSON.stringify({ - version: 6, - tools: { claude: { plugins: [{ name: PLUGIN_NAME, version: "0.0.1-stale" }] } }, - }), - ); - - const result = runJournal(journalScript, repo, "00000000-0000-4000-8000-0000000pv91"); - - assert.equal(result.status, 0, result.stderr); - const [sessionStart] = readRunFileLines(repo); - assert.equal(sessionStart.plugin_version, REAL_PLUGIN_VERSION); -}); diff --git a/scripts/__tests__/aidd-telemetry-runs-dir.test.js b/scripts/__tests__/aidd-telemetry-runs-dir.test.js deleted file mode 100644 index b5ded8034..000000000 --- a/scripts/__tests__/aidd-telemetry-runs-dir.test.js +++ /dev/null @@ -1,106 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const test = require("node:test"); - -// Under a git hook, git exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE, which -// would point every child git call here at the real repository instead of the -// temporary one. Strip them, or "git remote add origin" edits the repo running -// the test. -const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")), -); - - -const root = path.resolve(__dirname, "../.."); - -function writeTelemetryConfig(repo) { - fs.mkdirSync(path.join(repo, ".aidd"), { recursive: true }); - fs.writeFileSync( - path.join(repo, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true, endpoint: "http://127.0.0.1:4318" } }), - ); -} - -test("AIDD_RUNS_DIR overrides where runs are written", () => { - const os = require("node:os"); - const { spawnSync } = require("node:child_process"); - const script = path.join(root, "plugins/aidd-telemetry/hooks/journal.cjs"); - const repo = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-override-")); - const runs = path.join(repo, "local-runs"); - const defaultRunsDir = path.join(repo, "aidd_docs", "runs"); - - spawnSync("git", ["init", "-q", repo], { encoding: "utf8", env: CLEAN_ENV }); - fs.mkdirSync(defaultRunsDir, { recursive: true }); - writeTelemetryConfig(repo); - - spawnSync(process.execPath, [script, "session-start"], { - input: JSON.stringify({ - session_id: "override-1", - hook_event_name: "SessionStart", - cwd: repo, - transcript_path: "/home/user/.claude/projects/x/override.jsonl", - }), - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: runs }, - encoding: "utf8", - }); - - const written = fs.readdirSync(runs, { recursive: true }).filter((f) => String(f).endsWith(".jsonl")); - assert.equal(written.length, 1, "the record did not land under AIDD_RUNS_DIR"); - - const defaultWritten = fs.readdirSync(defaultRunsDir).filter((f) => f.endsWith(".jsonl")); - assert.equal(defaultWritten.length, 0, "the default aidd_docs/runs/ location was used anyway"); - - fs.rmSync(repo, { recursive: true, force: true }); -}); - -test("a user-named AIDD_RUNS_DIR keeps the permissions its owner gave it", () => { - if (process.platform === "win32") return; - const os = require("node:os"); - const { spawnSync } = require("node:child_process"); - const script = path.join(root, "plugins/aidd-telemetry/hooks/journal.cjs"); - const repo = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-mode-")); - const runs = path.join(repo, "shared-runs"); - - spawnSync("git", ["init", "-q", repo], { encoding: "utf8", env: CLEAN_ENV }); - fs.mkdirSync(path.join(repo, "aidd_docs", "runs"), { recursive: true }); - fs.mkdirSync(runs, { recursive: true, mode: 0o755 }); - fs.chmodSync(runs, 0o755); - writeTelemetryConfig(repo); - - spawnSync(process.execPath, [script, "session-start"], { - input: JSON.stringify({ - session_id: "mode-1", - hook_event_name: "SessionStart", - cwd: repo, - transcript_path: "/home/user/.claude/projects/x/mode.jsonl", - }), - env: { ...CLEAN_ENV, AIDD_RUNS_DIR: runs }, - encoding: "utf8", - }); - - assert.equal(fs.readdirSync(runs).length, 1, "the record was not written"); - assert.equal( - fs.statSync(runs).mode & 0o777, - 0o755, - "the plugin re-permissioned a directory the user named", - ); - - fs.rmSync(repo, { recursive: true, force: true }); -}); - -test("a task written as a single .md file is recorded like a folder - the path is returned whole, not reduced to a task_id", () => { - const { taskFolderRelativePath } = require("../../plugins/aidd-telemetry/hooks/lib/file-writes.cjs"); - const repo = "/repo"; - assert.equal( - taskFolderRelativePath(repo, "/repo/aidd_docs/tasks/2026_08/2026_08_14_telemetry-v1/plan.md"), - "aidd_docs/tasks/2026_08/2026_08_14_telemetry-v1/plan.md", - ); - assert.equal( - taskFolderRelativePath(repo, "/repo/aidd_docs/tasks/2026_06/2026_06_19-rolling-weekly-releases.md"), - "aidd_docs/tasks/2026_06/2026_06_19-rolling-weekly-releases.md", - ); - assert.equal(taskFolderRelativePath(repo, "/repo/aidd_docs/tasks/2026_08/notes.txt"), null); - assert.equal(taskFolderRelativePath(repo, "/repo/src/index.ts"), null); - assert.equal(taskFolderRelativePath(repo, "/repobis/aidd_docs/tasks/2026_08/x/plan.md"), null); -}); diff --git a/scripts/__tests__/aidd-telemetry-step-end.test.js b/scripts/__tests__/aidd-telemetry-step-end.test.js deleted file mode 100644 index b49089c0c..000000000 --- a/scripts/__tests__/aidd-telemetry-step-end.test.js +++ /dev/null @@ -1,127 +0,0 @@ -// A skill's own end, read out of a tool call's free-form arguments and never from a field a -// host populates. Nothing any host emits says when a skill's work finishes: a `Skill` call's -// `tool_result` comes back in a tenth of a second, which is the dispatch and not the -// completion, so the only party that can say it is the skill itself. -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const test = require("node:test"); - -const { declaredStepEnd } = require("../../plugins/aidd-telemetry/hooks/lib/step-ends.cjs"); - -const pluginsDir = path.join(__dirname, "..", "..", "plugins"); - -test("reads the skill named by an end marker in a tool call's own arguments", () => { - const payload = { - tool_name: "Bash", - tool_input: { command: 'echo "aidd:step-end aidd-dev:01-plan"' }, - }; - - assert.equal(declaredStepEnd(payload), "aidd-dev:01-plan"); -}); - -test("reads it out of Copilot's toolArgs, which carries a JSON string rather than an object", () => { - const payload = { - toolName: "bash", - toolArgs: JSON.stringify({ command: "echo aidd:step-end aidd-context:04-skill-generate" }), - }; - - assert.equal(declaredStepEnd(payload), "aidd-context:04-skill-generate"); -}); - -test("answers null for a tool call that names no end at all", () => { - const payload = { tool_name: "Bash", tool_input: { command: "pnpm test" } }; - - assert.equal(declaredStepEnd(payload), null); -}); - -// The marker must name its skill. A bare marker would have to close "whatever step is open", -// which closes the wrong one the moment a skill invokes another. -test("answers null for a marker that names no skill", () => { - const payload = { tool_name: "Bash", tool_input: { command: "echo aidd:step-end" } }; - - assert.equal(declaredStepEnd(payload), null); -}); - -// The same guard `sanitizeSkillName` gives `step_start`: a name is a name, never a path -// fragment or a shell fragment that a later reader would have to defend against. -test("refuses a skill name carrying anything but a skill name", () => { - const payload = { - tool_name: "Bash", - tool_input: { command: "echo aidd:step-end ../../etc/passwd" }, - }; - - assert.equal(declaredStepEnd(payload), null); -}); - -// Every skill that declares its own end must do it in a form the hook actually reads, naming -// the skill it actually is. Written as a sweep rather than a list, so a second skill opting -// in is covered without this file being told - and so a marker that drifts from the pattern, -// or names another skill, fails here rather than silently closing nothing for the life of -// the release. -function skillFilesDeclaringAnEnd() { - const found = []; - const walk = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - continue; - } - if (entry.name !== "SKILL.md") continue; - const text = fs.readFileSync(full, "utf8"); - if (text.includes("aidd:step-end")) found.push({ full, text }); - } - }; - walk(pluginsDir); - return found; -} - -test("every skill declaring its own end declares it in the form the hook reads", () => { - const declaring = skillFilesDeclaringAnEnd(); - assert.ok(declaring.length > 0, "no skill declares an end - the mechanism has no user"); - - for (const { full, text } of declaring) { - const relative = path.relative(pluginsDir, full); - const [plugin, , skillDir] = relative.split(path.sep); - const expected = `${plugin}:${skillDir}`; - - const read = declaredStepEnd({ tool_input: { command: text } }); - assert.equal(read, expected, `${relative} declares an end the hook reads as ${read}`); - } -}); - -// The reader's own list of skills that open a flow, read from where it is declared rather -// than repeated here - a fourth orchestrator added to that set is covered by this test -// without this file being told, the same way the test above covers a fifth skill declaring -// an end. Parsed out of the TypeScript source because that set is the one place the fact -// lives; a copy kept here would be the second, and the two would disagree first. -function orchestratingSkillsTheReaderKnows() { - const source = fs.readFileSync( - path.join(__dirname, "..", "..", "cli", "src", "contexts", "telemetry", "domain", "flow-attribution.ts"), - "utf8" - ); - const declared = /ORCHESTRATING_SKILLS: ReadonlySet = new Set\(\[([^\]]*)\]\)/u.exec( - source - ); - assert.ok(declared, "the reader no longer declares its orchestrating skills as a literal set"); - return [...declared[1].matchAll(/"([^"]+)"/gu)].map((match) => match[1]); -} - -test("every skill that opens a flow also says when that flow is over", () => { - // The reader does not close a flow at a `turn_end`, so without the marker a flow closes on - // the next orchestrating step_start or on the journal's last witnessed moment - and an - // orchestration that never says it is done owns everything the session did afterwards. - const qualified = orchestratingSkillsTheReaderKnows().filter((skill) => skill.includes(":")); - assert.ok(qualified.length > 0, "no orchestrating skill is named in the plugin-qualified form"); - - for (const skill of qualified) { - const [plugin, name] = skill.split(":"); - const skillFile = path.join(pluginsDir, plugin, "skills", name, "SKILL.md"); - assert.ok(fs.existsSync(skillFile), `${skill} names no skill in this tree`); - const read = declaredStepEnd({ - tool_input: { command: fs.readFileSync(skillFile, "utf8") }, - }); - assert.equal(read, skill, `${skill} declares no end the hook reads as its own`); - } -}); diff --git a/scripts/__tests__/aidd-telemetry-task-declaration.test.js b/scripts/__tests__/aidd-telemetry-task-declaration.test.js deleted file mode 100644 index c5600d055..000000000 --- a/scripts/__tests__/aidd-telemetry-task-declaration.test.js +++ /dev/null @@ -1,78 +0,0 @@ -// One real, live capture per host that can declare a task, rather than a hand-written payload -// - which would only prove the reader agrees with itself, never with anything a host sends. -// For OpenCode it is the call `opencode-plugin.js` builds from a genuinely captured event. -// See fixtures/README.md for exactly what each fixture rests on. -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const test = require("node:test"); - -const { declaredTaskPath } = require("../../plugins/aidd-telemetry/hooks/lib/task-declared.cjs"); -const { detectHost } = require("../../plugins/aidd-telemetry/hooks/lib/host.cjs"); - -const fixturesDir = path.join(__dirname, "fixtures"); - -function loadFixture(name) { - return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8")); -} - -const TASK_RELATIVE_PATH = "aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md"; - -// One real captured payload per host that can declare, and the host it should resolve to - -// the fixture proves the reader against a genuine shape only if detectHost agrees it is one. -const TASK_DECLARED_FIXTURE_BY_HOST = { - "claude-code": "claude-code-task-declared.json", - copilot: "copilot-task-declared.json", - cursor: "cursor-task-declared.json", - codex: "codex-task-declared.json", - opencode: "opencode-task-declared.json", -}; - -for (const [host, fixtureName] of Object.entries(TASK_DECLARED_FIXTURE_BY_HOST)) { - test(`${host}'s own captured payload declares the task path it actually names`, () => { - const payload = loadFixture(fixtureName); - assert.equal(detectHost(payload), host); - assert.equal(declaredTaskPath(payload), TASK_RELATIVE_PATH); - }); -} - -// The negative: a captured payload naming a SKILL.md path, not a task path, declares -// nothing. No new capture needed - these four already exist, proven real elsewhere in this -// suite for the step dimension, and a path outside a task folder is exactly what they are. -const SKILL_FIXTURE_BY_HOST = { - "claude-code": "claude-code-post-tool-use-skill.json", - copilot: "copilot-post-tool-use-skill.json", - codex: "codex-post-tool-use-skill-read.json", - cursor: "cursor-post-tool-use-skill-read.json", -}; - -for (const [host, fixtureName] of Object.entries(SKILL_FIXTURE_BY_HOST)) { - test(`${host}'s own captured SKILL.md read declares no task - a path outside a task folder is not a declaration`, () => { - const payload = loadFixture(fixtureName); - assert.equal(declaredTaskPath(payload), null); - }); -} - -// Mutation proof, per host: renaming a key *inside* the arguments proves nothing, since -// firstTaskPathIn walks every string value regardless of its key. The drift that matters is -// the wrapper itself - a host renaming `tool_input` would leave the reader finding nothing - -// so that is the key this proof renames. -const WRAPPER_KEY = "tool_input"; -const RENAMED_WRAPPER_CASE_BY_HOST = Object.keys(TASK_DECLARED_FIXTURE_BY_HOST); - -for (const host of RENAMED_WRAPPER_CASE_BY_HOST) { - test(`${host}: renaming the ${WRAPPER_KEY} wrapper the path lives in turns the declaration reader red`, () => { - const payload = loadFixture(TASK_DECLARED_FIXTURE_BY_HOST[host]); - assert.equal( - declaredTaskPath(payload), - TASK_RELATIVE_PATH, - "sanity: the un-mutated fixture still declares" - ); - - payload.arguments = payload[WRAPPER_KEY]; - delete payload[WRAPPER_KEY]; - - assert.equal(declaredTaskPath(payload), null); - }); -} - diff --git a/scripts/__tests__/aidd-telemetry-task-path-pattern-parity.test.js b/scripts/__tests__/aidd-telemetry-task-path-pattern-parity.test.js deleted file mode 100644 index 5e0a71570..000000000 --- a/scripts/__tests__/aidd-telemetry-task-path-pattern-parity.test.js +++ /dev/null @@ -1,35 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const { describe, it } = require("node:test"); - -const ROOT = path.resolve(__dirname, "../.."); - -/** - * `TASK_PATH_PATTERN` is duplicated on purpose - opencode-plugin.js's own doc comment - * explains why it cannot import `hooks/lib/task-declared.cjs` - but nothing pinned the two - * literals to each other, so one could drift and a task declaration would stop matching on - * OpenCode alone, silently, with no red anywhere. This reads both source files and compares - * the literal text of the assignment, not its behaviour: a regex that matches the same - * strings today but is spelled differently would still be a drift worth catching. - */ - -function taskPathPatternLiteral(file) { - const text = fs.readFileSync(path.join(ROOT, file), "utf8"); - const match = text.match(/const TASK_PATH_PATTERN =\s*([\s\S]*?);/u); - assert.ok(match, `${file} no longer declares TASK_PATH_PATTERN the way this guard expects`); - return match[1].replace(/\s+/gu, " ").trim(); -} - -describe("TASK_PATH_PATTERN stays identical between its two copies", () => { - it("opencode-plugin.js's own copy matches lib/task-declared.cjs's, character for character", () => { - const canonical = taskPathPatternLiteral("plugins/aidd-telemetry/hooks/lib/task-declared.cjs"); - const duplicate = taskPathPatternLiteral("plugins/aidd-telemetry/hooks/opencode-plugin.js"); - - assert.equal( - duplicate, - canonical, - "opencode-plugin.js's TASK_PATH_PATTERN drifted from lib/task-declared.cjs's - fix the copy here to match" - ); - }); -}); diff --git a/scripts/__tests__/aidd-telemetry-trailer-repair.test.js b/scripts/__tests__/aidd-telemetry-trailer-repair.test.js deleted file mode 100644 index bffdd1633..000000000 --- a/scripts/__tests__/aidd-telemetry-trailer-repair.test.js +++ /dev/null @@ -1,459 +0,0 @@ -const assert = require("node:assert/strict"); -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const test = require("node:test"); - -const repo = path.resolve(__dirname, "../.."); -const HOOK = path.join(repo, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); -const DELEGATE = "aidd-session-trailer.sh"; -const SESSION = "33333333-3333-4333-8333-333333333333"; - -/** Git exports `GIT_DIR`, `GIT_INDEX_FILE` and friends into everything it spawns, hooks - * included — so under `lefthook run pre-commit` this file's own `git init` and `rev-parse` - * would answer for the repository being committed rather than the temporary one. Stripped, - * or these tests pass when run by hand and fail only inside a commit, which is the least - * useful moment for them to be wrong. */ -const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")) -); - -/** - * The trailer's call site, put back after something removed it. - * - * Every repair case reproduces the failure by its shape — a `prepare-commit-msg` replaced - * between two commits — never by its brand, since the repair asks only whether the line is - * there. The two cases that do name lefthook and husky assert the opposite, that a marker - * file makes the repair decline, and a marker file at the root is the entire fixture. - */ -function withRepo(run, nested = "") { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-trailer-repair-")); - try { - spawnSync("git", ["init", "-q", "."], { cwd: root, env: CLEAN_ENV }); - const sessionCwd = nested === "" ? root : path.join(root, nested); - fs.mkdirSync(sessionCwd, { recursive: true }); - fs.mkdirSync(path.join(root, ".aidd"), { recursive: true }); - fs.writeFileSync( - path.join(root, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true } }) - ); - // Resolved against the cwd the hook is given, which is what the hook does: git prints - // `--git-path` relative to the directory it ran in. Every case below runs from `root`, - // where both bases coincide, so the nested case supplies a different cwd. - const hooks = String( - spawnSync("git", ["rev-parse", "--git-path", "hooks"], { - cwd: sessionCwd, - encoding: "utf8", - env: CLEAN_ENV, - }).stdout - ).trim(); - const absoluteHooks = path.resolve(sessionCwd, hooks); - fs.mkdirSync(absoluteHooks, { recursive: true }); - run({ root, sessionCwd, hooksDir: absoluteHooks }); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -} - -function installDelegate(hooksDir) { - const delegatePath = path.join(hooksDir, DELEGATE); - fs.writeFileSync(delegatePath, "#!/bin/sh\nexit 0\n"); - fs.chmodSync(delegatePath, 0o755); - // The physical path, because that is what the repair writes: it builds the line from the - // realpath its own containment guard approved, so a line built here from the unresolved - // spelling would differ on any machine whose temp directory is reached through a link — - // every macOS one, where `/var` is a link to `/private/var`. - const resolved = path.join(fs.realpathSync(hooksDir), DELEGATE); - return `sh "${resolved.replace(/\\/gu, "/")}" "$@"`; -} - -/** A regeneration, reproduced: the file a person's other tool owns, replaced whole. */ -function regenerateHook(hooksDir) { - fs.writeFileSync( - path.join(hooksDir, "prepare-commit-msg"), - "#!/bin/sh\n# generated — do not edit\nexit 0\n" - ); - fs.chmodSync(path.join(hooksDir, "prepare-commit-msg"), 0o755); -} - -function sessionStart(cwd, event = "session-start") { - return spawnSync(process.execPath, [HOOK, event], { - cwd, - encoding: "utf8", - // Claude Code's own shape: the host is recognised by its transcript path living under - // `/projects/`, which is what `hooks/lib/host.cjs` matches on. - input: JSON.stringify({ - session_id: SESSION, - cwd, - transcript_path: `/home/dev/.claude/projects/-repo/${SESSION}.jsonl`, - hook_event_name: "SessionStart", - source: "startup", - }), - env: { - ...CLEAN_ENV, - HOME: path.join(cwd, "aidd-home"), - AIDD_RUNS_DIR: path.join(cwd, "aidd-runs"), - }, - }); -} - -function hookText(hooksDir) { - const at = path.join(hooksDir, "prepare-commit-msg"); - return fs.existsSync(at) ? fs.readFileSync(at, "utf8") : null; -} - -test("a hook another tool regenerated calls the delegate again after the next session", () => { - withRepo(({ root, hooksDir }) => { - const line = installDelegate(hooksDir); - regenerateHook(hooksDir); - assert.equal(hookText(hooksDir).includes(line), false, "the regeneration must have erased it"); - - assert.equal(sessionStart(root).status, 0); - - const after = hookText(hooksDir); - assert.ok(after.includes(line), "the call site is back"); - assert.ok(after.includes("# generated — do not edit"), "and everything else is kept"); - }); -}); - -test("a hook that never existed is created with the call site", () => { - withRepo(({ root, hooksDir }) => { - const line = installDelegate(hooksDir); - - assert.equal(sessionStart(root).status, 0); - - assert.ok(hookText(hooksDir).includes(line)); - }); -}); - -// `aidd telemetry off` deletes the delegate. Nothing may resurrect what off removed. -test("nothing is written when no delegate is installed", () => { - withRepo(({ root, hooksDir }) => { - regenerateHook(hooksDir); - const before = hookText(hooksDir); - - assert.equal(sessionStart(root).status, 0); - - assert.equal(hookText(hooksDir), before); - }); -}); - -test("a hook that already calls the delegate is left byte for byte alone", () => { - withRepo(({ root, hooksDir }) => { - const line = installDelegate(hooksDir); - fs.writeFileSync(path.join(hooksDir, "prepare-commit-msg"), `#!/bin/sh\n${line}\n`); - const before = hookText(hooksDir); - - assert.equal(sessionStart(root).status, 0); - - assert.equal(hookText(hooksDir), before); - }); -}); - -// `tool-used` fires on every tool call. The journal already draws this line for its own -// unrecognised-payload path, with the cost written down; the repair inherits it. -test("tool-used never repairs, whatever is missing", () => { - withRepo(({ root, hooksDir }) => { - const line = installDelegate(hooksDir); - regenerateHook(hooksDir); - - assert.equal(sessionStart(root, "tool-used").status, 0); - - assert.equal(hookText(hooksDir).includes(line), false); - }); -}); - -// A repository that never turned measurement on is not one to write hooks into. -test("nothing is written when measurement is off for the project", () => { - withRepo(({ root, hooksDir }) => { - fs.writeFileSync( - path.join(root, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: false } }) - ); - const line = installDelegate(hooksDir); - regenerateHook(hooksDir); - - assert.equal(sessionStart(root).status, 0); - - assert.equal(hookText(hooksDir).includes(line), false); - }); -}); - -/** - * A file the filesystem says cannot be written is left exactly as it is, and the session - * survives. `rename` needs the *directory*, not the file, so without an explicit check a - * `0444` hook is silently replaced and left reading `0444` — its content changed while its - * permissions say it could not be. - */ -test("an unwritable hook file is left alone, and costs the session nothing", () => { - withRepo(({ root, hooksDir }) => { - installDelegate(hooksDir); - const hookPath = path.join(hooksDir, "prepare-commit-msg"); - fs.writeFileSync(hookPath, "#!/bin/sh\n# theirs\n"); - const before = fs.readFileSync(hookPath, "utf8"); - fs.chmodSync(hookPath, 0o444); - try { - assert.equal(sessionStart(root).status, 0); - assert.equal(fs.readFileSync(hookPath, "utf8"), before); - } finally { - fs.chmodSync(hookPath, 0o644); - } - }); -}); - -// `open(2)` applies the umask to the mode it is handed, so a staged write narrows a `0770` -// hook to `0750`, and narrowing is as much a change to somebody else's file as widening. -// -// Asserted as "the mode the repair found is the mode it left", never against a literal: -// Windows has no POSIX permission bits and reports `0o666` for every writable file. -test("a repair does not narrow a hook's group permissions", () => { - withRepo(({ root, hooksDir }) => { - installDelegate(hooksDir); - const hookPath = path.join(hooksDir, "prepare-commit-msg"); - fs.writeFileSync(hookPath, "#!/bin/sh\n# generated\n"); - fs.chmodSync(hookPath, 0o770); - const before = fs.statSync(hookPath).mode & 0o777; - - assert.equal(sessionStart(root).status, 0); - - assert.equal(fs.statSync(hookPath).mode & 0o777, before); - }); -}); - -/** - * The atomicity criterion, asserted on the one thing only `rename` produces: a different - * inode. A direct `writeFileSync` truncates and refills the file it already has, so the inode - * survives, and a test asserting only that no staging file remains is equally true when - * nothing is ever staged. - */ -test("a repair replaces the hook rather than truncating it, so no reader sees it half-written", () => { - withRepo(({ root, hooksDir }) => { - installDelegate(hooksDir); - const hookPath = path.join(hooksDir, "prepare-commit-msg"); - fs.writeFileSync(hookPath, "#!/bin/sh\n# generated\n"); - const before = fs.statSync(hookPath).ino; - - assert.equal(sessionStart(root).status, 0); - - assert.notEqual(fs.statSync(hookPath).ino, before); - }); -}); - -test("a hooks directory that is a symlink into the working tree is never written to", () => { - withRepo(({ root }) => { - const shared = path.join(root, ".githooks"); - fs.mkdirSync(shared, { recursive: true }); - fs.writeFileSync(path.join(shared, DELEGATE), "#!/bin/sh\nexit 0\n"); - fs.chmodSync(path.join(shared, DELEGATE), 0o755); - const teamHook = path.join(shared, "prepare-commit-msg"); - fs.writeFileSync(teamHook, "#!/bin/sh\n# the team's own\nexit 0\n"); - const before = fs.readFileSync(teamHook, "utf8"); - fs.rmSync(path.join(root, ".git", "hooks"), { recursive: true, force: true }); - fs.symlinkSync(path.join("..", ".githooks"), path.join(root, ".git", "hooks")); - - assert.equal(sessionStart(root).status, 0); - - assert.equal(fs.readFileSync(teamHook, "utf8"), before); - }); -}); - -// `rename` replaces the inode, so without carrying the mode across a `0700` hook would come -// back `0755` — widening a third party's file on a path meant to be conservative. Compared -// against the mode found rather than a literal, for the reason the narrowing case gives. -test("a repair keeps the hook's own permissions rather than widening them", () => { - withRepo(({ root, hooksDir }) => { - installDelegate(hooksDir); - const hookPath = path.join(hooksDir, "prepare-commit-msg"); - fs.writeFileSync(hookPath, "#!/bin/sh\n# generated\n"); - fs.chmodSync(hookPath, 0o700); - const before = fs.statSync(hookPath).mode & 0o777; - - assert.equal(sessionStart(root).status, 0); - - assert.equal(fs.statSync(hookPath).mode & 0o777, before); - }); -}); - -/** - * Staging beside the target needs write permission on the *directory*, where a direct write - * needs it only on the file. A `0555` hooks directory holding a writable hook was repairable - * before the atomic write existed, so the direct write is kept as the fallback rather than - * the capability being quietly dropped. - */ -test("a read-only hooks directory holding a writable hook is still repaired", () => { - withRepo(({ root, hooksDir }) => { - const line = installDelegate(hooksDir); - const hookPath = path.join(hooksDir, "prepare-commit-msg"); - fs.writeFileSync(hookPath, "#!/bin/sh\n# generated\n"); - fs.chmodSync(hookPath, 0o755); - fs.chmodSync(hooksDir, 0o555); - try { - assert.equal(sessionStart(root).status, 0); - assert.ok(hookText(hooksDir).includes(line)); - } finally { - fs.chmodSync(hooksDir, 0o755); - } - }); -}); - -/** - * The one case no other can see: every other starts the session at the repository root, where - * resolving against the root and against the cwd give the same answer. - * - * `git rev-parse --git-path hooks` prints relative to the directory it ran in, so resolving - * against the repository root sends a session started in `sub/deep` two levels ABOVE the - * checkout, which is where the repair then writes. - */ -test("a session started in a subdirectory repairs its own repository, and nothing above it", () => { - withRepo(({ root, sessionCwd, hooksDir }) => { - const line = installDelegate(hooksDir); - regenerateHook(hooksDir); - // Two levels up, which is where resolving `../../.git/hooks` against the repository root - // lands. One level up would assert nothing. - const outside = path.resolve(root, "..", "..", ".git", "hooks", "prepare-commit-msg"); - - assert.equal(sessionStart(sessionCwd).status, 0); - - assert.ok(hookText(hooksDir).includes(line), "the session's own hook is repaired"); - assert.equal(fs.existsSync(outside), false, "nothing was written above the repository"); - }, path.join("sub", "deep")); -}); - -/** - * `core.hooksPath` may point into the working tree — a checked-in `.githooks/` is a common way - * to share hooks with a team. `aidd telemetry on` writing the line once is a write a person - * asked for; this one is not, it recurs on every session, and it would dirty a tracked file - * with a machine-absolute path nobody can commit. - */ -test("a hooks directory inside the working tree is never written to", () => { - withRepo(({ root, hooksDir }) => { - const shared = path.join(root, ".githooks"); - fs.mkdirSync(shared, { recursive: true }); - spawnSync("git", ["config", "core.hooksPath", ".githooks"], { cwd: root, env: CLEAN_ENV }); - fs.writeFileSync(path.join(shared, DELEGATE), "#!/bin/sh\nexit 0\n"); - fs.chmodSync(path.join(shared, DELEGATE), 0o755); - const teamHook = path.join(shared, "prepare-commit-msg"); - fs.writeFileSync(teamHook, "#!/bin/sh\n# the team's own\nexit 0\n"); - const before = fs.readFileSync(teamHook, "utf8"); - void hooksDir; - - assert.equal(sessionStart(root).status, 0); - - assert.equal(fs.readFileSync(teamHook, "utf8"), before); - }); -}); - -// `writeFileSync` follows a symlink and edits whatever it points at — most usefully a file -// the team shares. The link is somebody's deliberate indirection, and it is left alone. -test("a prepare-commit-msg that is a symlink is left alone, target and all", () => { - withRepo(({ root, hooksDir }) => { - installDelegate(hooksDir); - const target = path.join(root, "shared-hook.sh"); - fs.writeFileSync(target, "#!/bin/sh\n# shared\nexit 0\n"); - fs.symlinkSync(target, path.join(hooksDir, "prepare-commit-msg")); - const before = fs.readFileSync(target, "utf8"); - - assert.equal(sessionStart(root).status, 0); - - assert.equal(fs.readFileSync(target, "utf8"), before); - assert.ok(fs.lstatSync(path.join(hooksDir, "prepare-commit-msg")).isSymbolicLink()); - }); -}); - -/** - * The fallback that keeps the journal alive on an older git. `rev-parse` fails atomically, so - * a git that does not understand `--git-path` answers non-zero for every option asked with - * it, and without the three-option retry the whole location read returns null and the session - * records nothing at all. - */ -test("a git that rejects --git-path still journals the session", () => { - withRepo(({ root }) => { - const stub = path.join(root, "stub"); - fs.mkdirSync(stub, { recursive: true }); - const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim(); - fs.writeFileSync( - path.join(stub, "git"), - `#!/bin/sh\nfor a in "$@"; do [ "$a" = "--git-path" ] && exit 129; done\nexec ${real} "$@"\n` - ); - fs.chmodSync(path.join(stub, "git"), 0o755); - - const result = spawnSync(process.execPath, [HOOK, "session-start"], { - cwd: root, - encoding: "utf8", - input: JSON.stringify({ - session_id: SESSION, - cwd: root, - transcript_path: `/home/dev/.claude/projects/-repo/${SESSION}.jsonl`, - hook_event_name: "SessionStart", - source: "startup", - }), - env: { - ...CLEAN_ENV, - PATH: `${stub}${path.delimiter}${CLEAN_ENV.PATH}`, - HOME: path.join(root, "aidd-home"), - AIDD_RUNS_DIR: path.join(root, "aidd-runs"), - }, - }); - - assert.equal(result.status, 0); - assert.ok( - fs.existsSync(path.join(root, "aidd-runs")) && - fs.readdirSync(path.join(root, "aidd-runs")).some((name) => name.endsWith(".jsonl")), - "the session is journalled even though git refused --git-path" - ); - }); -}); - -/** - * Where a hook manager owns `prepare-commit-msg`, the repair adds nothing: the fault here is - * not a missing line but a line that must never be added. `aidd telemetry on` installs no - * call site in such a repository, so a repair appending one would give the delegate two - * callers per commit. The marker file is the only fact that survives a regeneration, which is - * why the decision is taken from it and not from the hook's contents. - */ -const MANAGER_MARKERS = [ - ["lefthook", (root) => fs.writeFileSync(path.join(root, "lefthook.yml"), "pre-commit:\n")], - ["husky", (root) => fs.mkdirSync(path.join(root, ".husky"))], -]; - -for (const [manager, plant] of MANAGER_MARKERS) { - test(`nothing is written where ${manager} owns the hook`, () => { - withRepo(({ root, hooksDir }) => { - plant(root); - const line = installDelegate(hooksDir); - regenerateHook(hooksDir); - const before = hookText(hooksDir); - - assert.equal(sessionStart(root).status, 0); - - assert.equal(hookText(hooksDir), before, "the manager's own file is left untouched"); - assert.equal(hookText(hooksDir).includes(line), false, "and no second caller was added"); - }); - }); -} - -/** - * The marker list is spelled twice — here and in the CLI's `detectHookManager` — because this - * hook is zero-dependency CommonJS copied into a person's repository and can import nothing - * from `cli/`. Pinned against the CLI's own declaration read as text, never a third copy - * typed into this file, so a spelling added on one side fails here. - */ -test("the manager markers are the ones the CLI decides by", () => { - const declaration = fs.readFileSync( - path.join(repo, "cli", "src", "contexts", "telemetry", "domain", "telemetry-setup.ts"), - "utf8" - ); - const lefthook = declaration - .split("export const LEFTHOOK_MARKER_NAMES = [")[1] - .split("]")[0] - .match(/"([^"]+)"/gu) - .map((quoted) => quoted.slice(1, -1)); - const husky = declaration.match(/HUSKY_MARKER_NAME = "([^"]+)"/u)[1]; - - const { HOOK_MANAGER_MARKERS } = require( - path.join(repo, "plugins", "aidd-telemetry", "hooks", "lib", "trailer-repair.cjs") - ); - - assert.deepEqual([...HOOK_MANAGER_MARKERS], [...lefthook, husky]); -}); diff --git a/scripts/__tests__/architecture-rules.test.js b/scripts/__tests__/architecture-rules.test.js index 9eb7df1b8..6578bc37a 100644 --- a/scripts/__tests__/architecture-rules.test.js +++ b/scripts/__tests__/architecture-rules.test.js @@ -229,7 +229,7 @@ test("sweeping the repository's own plugins/ tree yields zero violations", () => assert.deepEqual(violations, []); // A sweep that never actually exercised a skill with action files would pass the same way — // this pins the sweep to the measured count so it cannot go vacuously green. - assert.equal(skillsWithActions, 48); + assert.equal(skillsWithActions, 45); }); test("a table that declares no action column cites nothing, whatever its cells read", () => { diff --git a/scripts/__tests__/comments-name-files-that-exist.test.js b/scripts/__tests__/comments-name-files-that-exist.test.js index b3aeff925..2e97f4156 100644 --- a/scripts/__tests__/comments-name-files-that-exist.test.js +++ b/scripts/__tests__/comments-name-files-that-exist.test.js @@ -18,17 +18,6 @@ const { REPO_ROOT: ROOT, repositoryPathExists } = require("../lib/repository-pat * as gone, or named by a test asserting it is gone. Listed one by one rather than inferred * from nearby words like "deleted", so adding one is a decision somebody makes on purpose. */ const NAMED_AS_HISTORY = Object.freeze({ - "cli/src/presentation/display/telemetry-check-display.ts": ["diagnose.cjs"], - "cli/src/contexts/telemetry/domain/telemetry-claim.ts": ["diagnose.cjs"], - "cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts": ["hook-trust.cjs"], - "cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts": ["identity.cjs"], - "cli/src/contexts/telemetry/domain/session-anchor.ts": ["session-anchor.cjs"], - "scripts/__tests__/aidd-telemetry-cost-skill.test.js": ["telemetry-report.cjs"], - "scripts/__tests__/telemetry-where-things-live.test.js": [ - "scripts/telemetry-check.cjs", - "telemetry-report.cjs", - "telemetry-switch.cjs", - ], }); /** Named inside a fixture or a runtime path a test builds, never a file of this repository. */ @@ -56,18 +45,6 @@ const NOT_A_REPOSITORY_FILE = Object.freeze({ // The build artefact every e2e run refuses to share, named as what is avoided. "cli/tests/e2e/helpers.ts": ["dist/cli.js"], "cli/tests/e2e/global-setup.ts": ["dist/cli.js"], - // A sample written path fed to a payload or a fixture: illustrative, not a claim that - // `cli/src/index.ts` exists. Tightening the basename fallback for a rooted token (below) - // newly reaches these; the file the token names is out of this pass's scope, and these - // four never intended to name a real one in the first place. - "cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts": [ - "cli/src/index.ts", - ], - "cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts": [ - "cli/src/index.ts", - ], - "cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts": ["cli/src/index.ts"], - "cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts": ["cli/src/index.ts"], }); const SOURCE_FILE_TOKEN = /^[\w./@-]+\.(?:ts|cjs|js|md)$/u; @@ -150,63 +127,6 @@ function namesSomethingReal(token, file, tracked, basenames) { return basenames.has(path.basename(token)); } -/** Every `.js` file the plugin's hooks actually ship. One, today: everything else there is - * `.cjs`, because the hooks run as CommonJS while OpenCode's loader needs an ESM entry. */ -function hookJsFiles(tracked) { - return new Set( - [...tracked] - .filter((file) => file.startsWith("plugins/aidd-telemetry/hooks/") && file.endsWith(".js")) - .map((file) => path.basename(file)) - ); -} - -/** - * The same rule as below, for a mention that carries no backticks. The hooks directory is the - * one place a narrow rule is safe: it ships exactly one `.js` file, so any other such name - * anywhere in it, or in the tests that describe it, is a `.cjs` written wrong. - */ -describe("a comment about the hooks names .cjs where the file is .cjs", () => { - it("names no .js that the hooks do not actually ship", () => { - const tracked = new Set(trackedFiles()); - const shipped = hookJsFiles(tracked); - const basenames = repositoryBasenames(tracked); - const scanned = [...tracked].filter( - (file) => - file.startsWith("plugins/aidd-telemetry/hooks/") || - file.startsWith("scripts/__tests__/opencode-plugin") || - file.startsWith("scripts/__tests__/aidd-telemetry-journal") - ); - const wrong = []; - - for (const file of scanned) { - const text = fs.readFileSync(path.join(ROOT, file), "utf8"); - // `[\w.-]+`, not `[\w-]+`: a filename can carry dots of its own, and capturing only - // the last segment reads a dotted test filename as `test.js` and flags a real file. - for (const match of text.matchAll(/([\w.-]+)\.js\b/gu)) { - const named = `${match[1]}.js`; - // A path inside a fixture or an assertion about somebody else's project file is not - // a claim about this plugin's own layout. - if (named === "index.js" && text.includes("/src/index.js")) continue; - if (shipped.has(named)) continue; - if (basenames.has(named)) continue; - // Inside the hooks, every module is `.cjs`; `opencode-plugin.js` is the single - // exception, and it is in `shipped` above. So any other such name here is a - // `.cjs` written wrong — including a placeholder like `.js`, which no lookup - // against a real filename could ever have caught. - if (file.startsWith("plugins/aidd-telemetry/hooks/")) { - wrong.push(`${file} names ${named}, and every module in hooks/ is .cjs`); - continue; - } - if (tracked.has(`plugins/aidd-telemetry/hooks/lib/${match[1]}.cjs`)) { - wrong.push(`${file} names ${named}, but the file it means is ${match[1]}.cjs`); - } - } - } - - assert.deepEqual(wrong, []); - }); -}); - /** This file's own path: excluded from the scan below, because the two allowlists it defines * carry, as literal string values, the very tokens this rule exists to flag. A catalog entry * naming the path it excuses is not this file claiming the path is real. */ diff --git a/scripts/__tests__/fixtures/README.md b/scripts/__tests__/fixtures/README.md index 2b3e40e7f..0a8cf924b 100644 --- a/scripts/__tests__/fixtures/README.md +++ b/scripts/__tests__/fixtures/README.md @@ -1,375 +1,5 @@ -# Fixtures: recorded hook payloads +# Fixtures -One real, captured `SessionStart` hook payload per host: `claude-code-session-start.json`, -`codex-session-start.json`, `copilot-session-start.json`, `cursor-session-start.json`. -Captured by the `out-*-skill` probe runs referenced in issue #663's 2026-08-20 comment. - -Four real, captured Claude Code `PostToolUse` payloads, one per observed tool: -`claude-code-post-tool-use-write.json`, `-edit.json`, `-notebook-edit.json` (the three tools -that write; `Write`/`Edit` carry `tool_input.file_path`, `NotebookEdit` carries -`tool_input.notebook_path` instead), and `-bash.json` (a non-write tool, captured to prove the -tool-name whitelist actually rejects something rather than never being exercised). Captured by -the `out-cc-skill` probe run. - -Two real, captured `PostToolUse` payloads for the path-family extractor, captured while a -skill's own `SKILL.md` was read mid-session — the shape the path-family extractor needs, not -a schema: - -- `codex-post-tool-use.json` — captured by `out-codex-skill`. `tool_name` is `Bash` (Codex's - hook names every shell-backed tool `Bash`, not the `exec_command` its own transcripts - record — see plan.md); the `SKILL.md` path lives inside `tool_input.command`, not a - dedicated field. -- `cursor-post-tool-use.json` — captured by `out-cursor-skill`. `tool_name` is `Read`; the - `SKILL.md` path lives in `tool_input.file_path`. - -## The four step-opening payloads - -One per host, each the call that opens a step. Two families, and the fixtures are what prove -the split is real rather than a tidy story: - -- `claude-code-post-tool-use-skill.json` — `tool_name` is `Skill`, the name sits in - `tool_input.skill`, and the payload carries `prompt_id`: the same value the CLI sink stores - as its turn key, which is why a step joins to its cost by identifier here and by ordering - elsewhere. -- `copilot-post-tool-use-skill.json` — `toolName` is `skill`, and `toolArgs` is a **JSON - string**, not an object. It has to be parsed. No turn identifier reaches a Copilot hook. -- `codex-post-tool-use-skill-read.json` — no skill is named anywhere. Only a relative - `SKILL.md` path inside `tool_input.command`, on a tool the hook calls `Bash`. -- `cursor-post-tool-use-skill-read.json` — no skill is named either. An absolute `SKILL.md` - path in `tool_input.file_path`, with the turn identifier spelled `generation_id`. - -A fifth belongs to this set: `copilot-compat-post-tool-use-skill.json`, Copilot's second -payload shape invoking a skill. See "Copilot's second shape, invoking a skill" below — -kept out of this list because it is a second shape for a host already listed here, not a -fifth host. - -These are recordings, not hand-written examples — a hand-written fixture would encode the -assumption being tested rather than what a host actually sends. - -## The task-declaration payloads - -`plugins/aidd-telemetry/hooks/lib/task-declared.cjs`'s reader asks nothing of a host beyond -what calling a tool already requires: any tool call whose own arguments name a file under an -`aidd_docs/tasks//…` folder is read as that session declaring the task, regardless -of tool name or event. Before this set, that reader was tested only against hand-written -payloads (see `aidd-telemetry-journal.test.js`'s prior `readTaskPayload()`), which proves the -reader agrees with itself, not with anything a host actually sends. - -`claude-code-task-declared.json`, `copilot-task-declared.json`, `cursor-task-declared.json` -and `codex-task-declared.json` are real, live captures — 2026-08-31, each host's own current -plugin installed through `aidd setup --ai --plugins aidd-telemetry` in a throwaway -project, asked to read a file inside a task folder. Captured by temporarily replacing the -installed `journal.cjs` entry point with a wrapper that tees its raw stdin to a file and -then execs the real script unchanged. `opencode-task-declared.json` is real too, from the -same date, captured a different way - OpenCode's plugin surface hands its hook a JS event -object in-process rather than stdin, so this one is built by `hooks/opencode-plugin.js` -itself from a genuinely captured event; see "OpenCode's plugin events" below. In every -case the project, the tool session and the wrapper (where one was used) are -all thrown away afterward; nothing in this repository or in any global tool config was -touched to take them. - -- **`claude-code-task-declared.json`** — a `Read` tool call; the path lives in - `tool_input.file_path`, the same field `claude-code-post-tool-use-write.json` above - carries for a write. -- **`copilot-task-declared.json`** — this capture landed on Copilot's **compat** shape - (`session_id`/`hook_event_name`/`timestamp`, see "Copilot's second shape" below), not the - canonical one `copilot-post-tool-use-skill.json` above shows. Expected, not measured as a - universal fact: this plugin's own `hooks.json` declares PascalCase event keys, and per - "Copilot's second shape" below that is exactly what selects the compat builder — so a - capture against the currently-shipped plugin lands here. The path lives in - `tool_input.path` — a key neither `copilot-post-tool-use-skill.json` (`toolArgs`, a - string) nor `copilot-compat-post-tool-use-skill.json` (`tool_input.skill`) had shown - before. The reader finds it regardless of the key name: it scans every string value - `tool_input` carries, never one named field. The canonical shape's `toolArgs`-as-string - form is not re-proven by this set; see "What this set does not re-prove" below. -- **`cursor-task-declared.json`** — a `Read` tool call, `tool_input.file_path`, structurally - identical to `cursor-post-tool-use-skill-read.json` above but pointed at a task folder - instead of a `SKILL.md`. -- **`codex-task-declared.json`** — a `Bash` tool call (Codex has no distinct "Read" tool - either; a shell command is the only shape a file read ever takes, exactly as - `codex-post-tool-use-skill-read.json` above already shows), `tool_input.command` in the - same `sed -n '1,120p' ` form. **Codex is now runnable in the environment this set - was captured in** (`codex-cli 0.151.0`) — this replaces what was, before 2026-08-31, a - derivation from `codex-post-tool-use-skill-read.json`'s shape. The genuine capture landed - on exactly the key set the derivation had already guessed: `session_id`, `turn_id`, - `transcript_path`, `cwd`, `hook_event_name`, `model`, `permission_mode`, `tool_name`, - `tool_input.command`, `tool_response`, `tool_use_id`. Codex gates hooks on trust — a - headless `codex exec` run needs `--dangerously-bypass-hook-trust`, or the hook never fires - and the run journals nothing; the diagnostic's own untrusted-hook reading - (`02-diagnose.md`) describes the same failure. One genuine difference the live capture - surfaced and the old derivation could not have known: `tool_use_id` on a real - `exec_command` call is shaped `exec-`, not the `call_` form the pre-existing - sibling fixtures above carry. The committed value, `exec-4f8b2e6a-…`, carries that shape - but is still a synthesised uuid, not the live run's own — every value in this fixture is - synthesised, per this set's own rule, the shape included. That leaves a real gap: - inspecting this file alone cannot tell a genuine capture that happened to synthesise this - shape apart from a careful re-synthesis of the retired derivation that decided to guess it - too. What actually distinguishes the two is not in the artefact — it is the commit message - of the change that captured it (`ead9a2f3`, "test: codex's task fixture becomes a genuine - capture, not a derivation"), which records the live run that produced it: project, host - and wrapper thrown away afterward, nothing touched in the real `~/.codex`. Naming that gap - here, rather than letting the file's shape alone imply more evidence than it carries, is - the same discipline `opencode-session-created.json` is held to below. -- **`opencode-task-declared.json`** — the tool-used call `hooks/opencode-plugin.js` builds - from a genuinely captured, completed tool part (`opencode-tool-part-completed.json`, see - "OpenCode's plugin events" below), the path in `tool_input.filePath`. Whether OpenCode - could declare at all was measured, not assumed: a bounded, three-further-session spike - (2026-08-31, `opencode 1.14.20`) found that a completed tool part's own arguments do - reach the plugin's `event` hook. `telemetryTaskAttributable: true` in - `cli/src/domain/tools/ai/opencode.ts` and `registry-conformance.unit.test.ts` both state - this now. - -The negative case — a captured payload whose path names something other than a task folder -— needs no new capture for four of the five hosts: `claude-code-post-tool-use-skill.json`, -`copilot-post-tool-use-skill.json`, `codex-post-tool-use-skill-read.json` and -`cursor-post-tool-use-skill-read.json` above each name a `SKILL.md` path, and -`declaredTaskPath` returns `null` against all four. OpenCode's own negative, -`opencode-tool-part-no-task.json`, is a fresh capture from the same live run as its positive -sibling — a completed `bash` call, `ls -la`, naming no task folder anywhere in its own -arguments; see "OpenCode's plugin events" below. - -**What this set does not re-prove.** `declaredTaskPath` reads `payload.tool_input`, falling -back to `payload.toolArgs` only when `tool_input` is absent — Copilot's canonical builder's -own shape, `toolArgs` as a JSON string, with no `tool_input` field at all -(`copilot-post-tool-use-skill.json` above). Every capture in this set, Copilot's included, -carries `tool_input`, so none exercises that fallback branch end-to-end through a real -payload. It is still tested — `aidd-telemetry-journal.test.js`'s -`"declaredTaskPath reads tool_input first and Copilot's toolArgs string only when -tool_input is absent"` asserts it directly — but against a literal, not a capture, because -it is testing the function's own precedence contract rather than a claim about what a host -sends. That test predates this set and was kept rather than replaced for that reason. - -## Copilot's second shape - -`copilot-compat-session-start.json`, `copilot-compat-post-tool-use.json`, -`copilot-compat-turn-end.json` — one per event, captured from a real -`@github/copilot@1.0.80` session running the framework's own installed plugin -(issue #681's `Done when`). Where `copilot-session-start.json` and -`copilot-post-tool-use-skill.json` are Copilot's canonical builder (`sessionId`, -`toolName`/`toolArgs` as a JSON string, no `hook_event_name`), these three are its -`_vsCodeCompat` builder: `session_id` and `hook_event_name` spelled Claude Code's way, -`tool_name`/`tool_input` as an object rather than a string, and a `timestamp` field both -shapes carry but no other host does. Which builder a session gets depends on how its -hooks are declared (PascalCase event keys trigger the compat rewrite - see #681's -source-read chain); a real install can produce either, so `detectHost` recognises both. - -`lib/step-starts.cjs`'s `STEP_START_BY_HOST.copilot` used to read only the canonical -shape's `toolName`/`toolArgs` - a compat `PostToolUse` never opened a step line, skill or -not. Left unfixed by #681 on purpose: that ticket's own scope was `detectHost` and the -session id it feeds, and closing the gap needed a capture #681 never took - a session that -actually invokes a skill under the compat builder, not just a Bash call. - -## Copilot's second shape, invoking a skill - -`copilot-compat-post-tool-use-skill.json` — captured 2026-08-22 against a real -`@github/copilot@1.0.80` session, asked by name to run the framework's own installed -`aidd-telemetry` plugin's `00-init` skill (issue #701's `Done when`). The two unknowns -#701 opened on both settle from this one payload: `tool_name` is `skill` - the same -lowercase spelling the canonical builder uses, not Claude Code's `Skill` - and -`tool_input` arrives as an **object** keyed `skill`, matching Claude Code's own -`tool_input.skill` rather than the canonical builder's JSON-string `toolArgs`. Neither -value was guessable from the other two captures: the compat builder mixes one host's -tool-name spelling with another host's argument shape. - -`STEP_START_BY_HOST.copilot` now tries both shapes in sequence - the canonical -`toolName`/`toolArgs` reader, then this compat `tool_name`/`tool_input` reader - so a -skill call opens a step line on either payload, and a non-skill call (`Bash`, per -`copilot-compat-post-tool-use.json`) still opens none on the compat shape either. - -## Copilot's event log is not a payload capture - -Copilot writes `~/.copilot/session-state//events.jsonl`, and each `hook.start` line -carries an `input` object. It is tempting to read that as "the payload the hook received", -which would make capturing a shape free — no session to run, no tokens to spend. - -**It is not the same object, and reading it that way produces a fixture of a shape no hook -ever sees.** In `@github/copilot`'s own bundle, the log line is emitted once per *event*, -from a hard-coded literal, before the per-hook payload builder runs. The `preToolUse` site -shows both objects a few statements apart: - -```js -// what gets logged -emitHookStart({ hookInvocationId: r, hookType: "preToolUse", - input: { sessionId, cwd, toolCalls: e.toolCalls.map(...) } }) -// what a hook is handed -{ timestamp: Date.now(), cwd: this.workingDir, toolName: u, toolArgs: d } -``` - -Different keys for the same event, and the hook-facing one carries `timestamp` while the -logged one does not. - -The consequence that matters here: **the log always shows the canonical camelCase spelling, -whichever builder the hook actually gets.** The `_vsCodeCompat` rewrite documented above is -selected per hook declaration, after the log line is written. - -That is not a hypothetical for this plugin. `plugins/aidd-telemetry/hooks/hooks.json` -declares `SessionStart`, `PostToolUse` and `Stop` — PascalCase, which is exactly what -triggers the rewrite. So **this framework's own hooks receive the compat snake_case payload** -while Copilot's event log records their events in camelCase. Counting spellings in that log -cannot tell the two builders apart, and a count of zero snake_case there is not evidence that -snake_case never arrives. The compat fixtures above are the direct counter-evidence: captured -from a hook's own stdin on 1.0.80, running this plugin, carrying `session_id` and -`hook_event_name`. - -Read hook declarations from the plugin, not from whatever else is installed on the machine. -`~/.copilot/hooks/` can hold files belonging to other applications entirely, and their event -casing says nothing about this one. - -Read on the readable `@github/copilot` 1.0.57 bundle. The 1.0.80 runtime binary is packed — -`strings` finds no `_vsCodeCompat`, `hookInvocationId` or `hook.start` in it — so the -architecture is proven on 1.0.57 and corroborated on 1.0.80 only by the stdin captures -above. Nothing here settles what 1.0.80 does differently, if anything. - -**So a Copilot shape fixture costs a session.** That session was run on 2026-08-28, against -`@github/copilot` 1.0.80, in a throwaway project declaring this plugin's own PascalCase -events with the hook command replaced by one that dumps stdin verbatim. What it settled: - -- **All five events arrive in the compat snake_case shape.** `session_id`, - `hook_event_name`, `tool_name`, `tool_input`, `initial_prompt`, `stop_reason`. Not one - camelCase `sessionId` on stdin — the exact opposite of what the event log showed for the - same events, which is the trap this section exists to name. -- **The three compat fixtures already here are faithful.** Their key sets were compared - against that stdin and match exactly. That had been assumed since 2026-08-21; it is now - measured. -- **Two shapes had never been captured**, and are added from this session: - `copilot-compat-user-prompt-submitted.json` and `copilot-compat-pre-tool-use-skill.json`. -- **`timestamp` is an ISO string on stdin**, where the event log records an integer. One - more place the two objects differ. -- **The compat builder renames built-in tools to Claude Code's PascalCase spelling but - leaves `skill` lowercase.** The same session captured `tool_name: "Read"` for a file read - and `tool_name: "skill"` for the skill call. `STEP_START_BY_HOST.copilot` keys on that - lowercase spelling, so the asymmetry is load-bearing rather than cosmetic. -- **The chain writes.** Those payloads through `journal.cjs` produce `session_start` (with - `vendor_id` equal to the session id Copilot itself printed), `step_start` naming the - skill, and `turn_end`. - -## Redaction - -Every fixture differs from what its probe captured in only two kinds of place: - -- `user_email` — replaced with the placeholder `user@example.com` (Cursor only carries this - field). -- Every absolute path — the real home-directory or scratchpad-tmp prefix replaced with - `/home/user/probe/...` (or, for a Cursor `transcript_path`, `/home/user/.cursor/...`), - keeping the path **shape** intact. This includes paths that appear twice inside one - payload, such as `cursor-post-tool-use.json`'s `tool_input.file_path` and its duplicate - inside `tool_output`. - -Detection reads `cursor_version`, `sessionId` (Copilot's canonical shape) / `session_id` -(every other host, and Copilot's compat shape) plus `timestamp` and `hook_event_name` -together, and the `/projects/` versus `/sessions/` segments of `transcript_path` — none of -which the redaction touches. `copilot-compat-turn-end.json`'s `transcript_path` is redacted -the same way as every other path, with its `.copilot/session-state/` segment kept intact: -that segment is shape, and shape is exactly what proves it matches neither the Codex nor -the Claude Code pattern. - -## Hosts declared vs. hosts that currently write - -All four hosts are declared in `lib/host.cjs`'s `DECLARED_HOSTS` and `lib/record.cjs`'s per-host -tables — declaring a host's session-id spelling and export-side `vendor_field` is independent -of whether the journal writes for it today: - -- **Copilot** writes now, on both shapes. The `hook_event_name`-never-arrives premise above - was read from a bundle, not a payload (issue #681); a real capture refuted it — the compat - shape carries `hook_event_name` with Claude Code's own spelling (`SessionStart`, - `PostToolUse`, `Stop`). What was true, and stays true: neither shape's payload carries an - event name journal.cjs's own dispatch reads from, since it dispatches from argv (the event - name `hooks.json` passes on its command line), and every replay in this suite drives that - argv the same way. `resolveEventName` reading `hook_event_name` is only ever the fallback - for a payload replayed with no argv at all. -- **Cursor** fires no `Stop`-equivalent hook when run headless (`sessionEnd` arrives instead, - and is not mapped to `turn-end` — see issue #680); its `SessionStart`-equivalent still - writes normally. `vendor_field` is `null` for Cursor specifically because its telemetry - export itself is unmeasured (an Enterprise team setting), not because of either open issue. - Cursor's own payload carries no `cwd` at all, only `workspace_roots` (a multi-root - workspace can list several, not all of them git repositories) — `lib/repo.cjs`'s - `readCwd`/`CWD_READER_BY_HOST` resolves the first entry that actually is one, the same way - `lib/record.cjs`'s `readSessionId` resolves Cursor's differently-spelled session id. - -## OpenCode's plugin events - -`opencode-session-idle.json`, `opencode-session-created.json` and the two -`opencode-tool-part-*.json` fixtures are not hook payloads — OpenCode's plugin surface -hands `hooks/opencode-plugin.js` a JS event object in-process, never a stdin JSON payload -the way every other host's hook does. They carry different weight. - -- **`opencode-session-idle.json`** is a real, live capture: `opencode 1.14.20` running - `opencode run` (`opencode/big-pickle`), 2026-08-31. `sessionID` is the only field the - event carries, matching `@opencode-ai/sdk`'s own `EventSessionIdle` type exactly — it - names the session, and nothing about where it ran. Synthesised: `sessionID`'s value only. -- **`opencode-session-created.json`** is *not* a delivery capture, and this entry is - the only place that gap is stated for it as bluntly as it needs to be: **`session.created` - was never observed reaching the plugin's own `event` hook.** One live `opencode 1.14.20` - run (`opencode run`, `opencode/big-pickle`, 2026-08-31, started with `--print-logs`) is the - measurement this rests on: the plugin's `event` hook demonstrably fired for roughly 38 - events of other types in that run (`session.updated`, `session.status`, `message.updated` - and others, written to a real `captured-events.jsonl`), the run's own log shows - `service=plugin path=…capture.js loading` followed later by - `service=bus type=session.created publishing`, and `session.created` never appears among - the captured events despite that publish line. That is the discriminating run - it is the - only one with debug logging turned on, so it is the only one that can distinguish - "never published" from "published but not delivered." - - Two further attempts neither confirm nor refute it, and are named here only so the count - is not inflated: a second `opencode run` without `--print-logs` also captured no - `session.created`, but with no log to show whether one was ever published, so it adds - nothing beyond the first; a bare `POST /session` API call captured zero plugin events of - any type in that run, so it says nothing about `session.created` specifically. This is not - a new defect - the plugin README already named it (`README.md`, "OpenCode misses a server - process's first session, and `opencode run` is always a first session") - this capture is - the first evidence behind that sentence rather than a doc comment asserting it. - - The fixture's shape rests on two things that *are* verified: `@opencode-ai/sdk@` (the - version opencode 1.14.20 ships) declares `EventSessionCreated` as - `{ type: "session.created", properties: { info: Session } }` - byte-identical to - `EventSessionUpdated`'s own declared shape - and a `session.updated` event *was* captured - live in the same run, carrying a real `info` object with this exact key set (`id`, `slug`, - `projectID`, `directory`, `title`, `version`, `permission`, `time.created`, - `time.updated`). The fixture is that real `info` shape, re-wrapped under the type the SDK - declares for creation. Every value in it is synthesised. Treat it as a shape derived from - a verified type declaration plus a genuine sibling capture, not as a recording of - `session.created` actually arriving - because it never did. - -## OpenCode's tool part - -Whether a tool call's own arguments ever reach the plugin's `event` hook was an open -question this deliverable exists to close, bounded before it started: three further -sessions, varying the model, then stop - see plan.md's own Decisions row for why. The -question was settled on the **first** of those three, not the last: `opencode 1.14.20`, -2026-08-31, model `opencode/ling-3.0-flash-fin-free`, prompted to list this directory's own -files. The model called its own `read` tool, and the plugin's `event` hook received a -`message.part.updated` event whose `part.type` was `"tool"` - three times, once per state -transition (`pending`, `running`, `completed`). - -This directly contradicts an earlier reading this deliverable's own spec.md carried in: -three *prior* sessions, model `opencode/big-pickle`, answered every prompt in text, -including one that asked for a tool call, and produced no tool part. Read the two together -and the earlier negative was never a property of -OpenCode's plugin surface - it was `big-pickle` declining to call a tool. `ling` did, on the -very same prompt shape, in the very same plugin. **Measured-and-not-observed on one model -is not evidence a tool part is undeliverable on the host** - the same distinction -`session.created` above exists to keep honest, now cutting the other way: this earlier -negative reading undersold what the plugin surface can actually deliver. - -Two further attempts, both spent getting the completed part's own full shape rather than -searching for whether one existed at all (already settled): a second `ling` run, same -prompt, full-object logging - reproduced the tool part in ~6 seconds, this time with every -field. A third session was not needed once the second reproduced cleanly, so the count -stands at two of the three further sessions the bound allowed, one held in reserve. - -- **`opencode-tool-part-completed.json`** — the completed `read` call from that second run: - `part.type: "tool"`, `part.tool: "read"`, `part.state.input.filePath` an absolute path, - `part.state.output` the file's own content. Redirected at a task-folder path for this - fixture, the same convention every `*-task-declared.json` capture uses. Every id - synthesised; the key set is exactly what arrived. -- **`opencode-tool-part-no-task.json`** — the negative, from the same run: a completed - `bash` call, `part.state.input.command: "ls -la"`, naming no task folder anywhere in its - own arguments. - -`hooks/opencode-plugin.js`'s `declaredTaskCallFor` reads only a **completed** tool part - -`pending` carries `state.input: {}`, empty and unsearchable, and reading `running` too would -call the declaration reader twice for the one real call `completed` already catches. Neither -fixture stands for `pending`/`running`; `aidd-telemetry-opencode-payloads.test.js` asserts -those states produce no journal call by mutating the completed fixture's own `state`, since -a genuine capture of an incomplete call carries nothing this reader would ever act on -anyway. +- `architecture-rules/`: skill, action and reference trees the architecture-rule tests read. +- `opencode-session-idle.json`, `opencode-tool-part-completed.json`: captured opencode plugin + events, read by the CLI's `opencode-hooks-bridge-mapping.integration.test.ts`. diff --git a/scripts/__tests__/fixtures/claude-code-post-tool-use-bash.json b/scripts/__tests__/fixtures/claude-code-post-tool-use-bash.json deleted file mode 100644 index 5b7893538..000000000 --- a/scripts/__tests__/fixtures/claude-code-post-tool-use-bash.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "session_id": "e5e7c58b-491b-485c-ae02-59c41ac4f934", - "transcript_path": "/home/user/probe/cc-home/projects/-home-user-probe-project/e5e7c58b-491b-485c-ae02-59c41ac4f934.jsonl", - "cwd": "/home/user/probe/project", - "hook_event_name": "PostToolUse", - "tool_name": "Bash", - "tool_input": { - "command": "echo hi", - "description": "Echo hi to stdout" - } -} diff --git a/scripts/__tests__/fixtures/claude-code-post-tool-use-edit.json b/scripts/__tests__/fixtures/claude-code-post-tool-use-edit.json deleted file mode 100644 index b0437e8c3..000000000 --- a/scripts/__tests__/fixtures/claude-code-post-tool-use-edit.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "session_id": "195fff46-dc16-4296-ab58-d625563d7f78", - "transcript_path": "/home/user/probe/cc-home/projects/-home-user-probe-project/195fff46-dc16-4296-ab58-d625563d7f78.jsonl", - "cwd": "/home/user/probe/project", - "hook_event_name": "PostToolUse", - "tool_name": "Edit", - "tool_input": { - "file_path": "/home/user/probe/project/aidd_docs/tasks/2026_08/2026_08_18_probe-task/notes.md", - "old_string": "probe", - "new_string": "probe\nsecond", - "replace_all": false - } -} diff --git a/scripts/__tests__/fixtures/claude-code-post-tool-use-notebook-edit.json b/scripts/__tests__/fixtures/claude-code-post-tool-use-notebook-edit.json deleted file mode 100644 index 29ff564e0..000000000 --- a/scripts/__tests__/fixtures/claude-code-post-tool-use-notebook-edit.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "session_id": "e5e7c58b-491b-485c-ae02-59c41ac4f934", - "transcript_path": "/home/user/probe/cc-home/projects/-home-user-probe-project/e5e7c58b-491b-485c-ae02-59c41ac4f934.jsonl", - "cwd": "/home/user/probe/project", - "hook_event_name": "PostToolUse", - "tool_name": "NotebookEdit", - "tool_input": { - "notebook_path": "/home/user/probe/project/aidd_docs/tasks/2026_08/2026_08_18_probe-task/scratch.ipynb", - "cell_id": "cell-0", - "new_source": "# Markdown Cell", - "cell_type": "markdown", - "edit_mode": "insert" - } -} diff --git a/scripts/__tests__/fixtures/claude-code-post-tool-use-skill.json b/scripts/__tests__/fixtures/claude-code-post-tool-use-skill.json deleted file mode 100644 index f04123e02..000000000 --- a/scripts/__tests__/fixtures/claude-code-post-tool-use-skill.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "session_id": "09195cc1-d8d3-4158-bb62-d0ecb1c04004", - "transcript_path": "/home/user/probe/cc-home-skill/projects/-home-user-probe-project-cc-skill/09195cc1-d8d3-4158-bb62-d0ecb1c04004.jsonl", - "cwd": "/home/user/probe/project-cc-skill", - "prompt_id": "16231051-346b-4bfd-addc-581f911ef878", - "permission_mode": "bypassPermissions", - "effort": { - "level": "high" - }, - "hook_event_name": "PostToolUse", - "tool_name": "Skill", - "tool_input": { - "skill": "probe-echo" - }, - "tool_response": { - "success": true, - "commandName": "probe-echo" - }, - "tool_use_id": "toolu_01L2UiKHFgAUckYuv1cSTBJ3", - "duration_ms": 62 -} diff --git a/scripts/__tests__/fixtures/claude-code-post-tool-use-write.json b/scripts/__tests__/fixtures/claude-code-post-tool-use-write.json deleted file mode 100644 index ec4ca17ba..000000000 --- a/scripts/__tests__/fixtures/claude-code-post-tool-use-write.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "session_id": "195fff46-dc16-4296-ab58-d625563d7f78", - "transcript_path": "/home/user/probe/cc-home/projects/-home-user-probe-project/195fff46-dc16-4296-ab58-d625563d7f78.jsonl", - "cwd": "/home/user/probe/project", - "hook_event_name": "PostToolUse", - "tool_name": "Write", - "tool_input": { - "file_path": "/home/user/probe/project/aidd_docs/tasks/2026_08/2026_08_18_probe-task/notes.md", - "content": "probe" - } -} diff --git a/scripts/__tests__/fixtures/claude-code-session-start.json b/scripts/__tests__/fixtures/claude-code-session-start.json deleted file mode 100644 index aba8a45fd..000000000 --- a/scripts/__tests__/fixtures/claude-code-session-start.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "session_id": "ffde6fda-14a8-4b32-8110-be1f1d13eebf", - "transcript_path": "/home/user/probe/cc-home/projects/-home-user-probe-project/ffde6fda-14a8-4b32-8110-be1f1d13eebf.jsonl", - "cwd": "/home/user/probe/project-plugin", - "hook_event_name": "SessionStart", - "source": "startup" -} diff --git a/scripts/__tests__/fixtures/claude-code-task-declared.json b/scripts/__tests__/fixtures/claude-code-task-declared.json deleted file mode 100644 index 2830331ea..000000000 --- a/scripts/__tests__/fixtures/claude-code-task-declared.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "session_id": "64148791-6b04-48b9-bac4-19d72bcc5194", - "transcript_path": "/home/user/probe/cc-home-task/projects/-home-user-probe-project-cc-task/64148791-6b04-48b9-bac4-19d72bcc5194.jsonl", - "cwd": "/home/user/probe/project-cc-task", - "prompt_id": "bc42f4c7-7702-436a-b09a-6803b1184b10", - "permission_mode": "bypassPermissions", - "effort": { - "level": "high" - }, - "hook_event_name": "PostToolUse", - "tool_name": "Read", - "tool_input": { - "file_path": "/home/user/probe/project-cc-task/aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md" - }, - "tool_response": { - "type": "text", - "file": { - "filePath": "/home/user/probe/project-cc-task/aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md", - "content": "# alpha\n\nSpec body.\n", - "numLines": 3, - "startLine": 1, - "totalLines": 3 - } - }, - "tool_use_id": "toolu_01BF4Ycq4bESWxda7dq5NE1W", - "duration_ms": 1 -} diff --git a/scripts/__tests__/fixtures/codex-post-tool-use-skill-read.json b/scripts/__tests__/fixtures/codex-post-tool-use-skill-read.json deleted file mode 100644 index d336be70e..000000000 --- a/scripts/__tests__/fixtures/codex-post-tool-use-skill-read.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "session_id": "01a01450-dc0f-71a3-ae06-7f1698ef866b", - "turn_id": "01a01450-e8a4-7fb1-b29d-f67e6cb10fff", - "transcript_path": "/home/user/probe/codex-home-skill/sessions/2026/08/18/rollout-2026-08-18T12-00-38-01a01450-dc0f-71a3-ae06-7f1698ef866b.jsonl", - "cwd": "/home/user/probe/project-codex-skill", - "hook_event_name": "PostToolUse", - "model": "gpt-5.5", - "permission_mode": "bypassPermissions", - "tool_name": "Bash", - "tool_input": { - "command": "sed -n '1,120p' .agents/skills/probe-echo/SKILL.md" - }, - "tool_response": "---\nname: probe-echo\ndescription: Writes a fixed word to a file. Use only when explicitly asked to run the probe-echo skill.\n---\n\n# probe-echo\n\nWrite the word `echoed` into a file named `echo.txt` in the current directory. Then stop, do nothing else.\n", - "tool_use_id": "call_08lgEbj7vpYSXMq7xcV2m3Lt" -} diff --git a/scripts/__tests__/fixtures/codex-post-tool-use.json b/scripts/__tests__/fixtures/codex-post-tool-use.json deleted file mode 100644 index d336be70e..000000000 --- a/scripts/__tests__/fixtures/codex-post-tool-use.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "session_id": "01a01450-dc0f-71a3-ae06-7f1698ef866b", - "turn_id": "01a01450-e8a4-7fb1-b29d-f67e6cb10fff", - "transcript_path": "/home/user/probe/codex-home-skill/sessions/2026/08/18/rollout-2026-08-18T12-00-38-01a01450-dc0f-71a3-ae06-7f1698ef866b.jsonl", - "cwd": "/home/user/probe/project-codex-skill", - "hook_event_name": "PostToolUse", - "model": "gpt-5.5", - "permission_mode": "bypassPermissions", - "tool_name": "Bash", - "tool_input": { - "command": "sed -n '1,120p' .agents/skills/probe-echo/SKILL.md" - }, - "tool_response": "---\nname: probe-echo\ndescription: Writes a fixed word to a file. Use only when explicitly asked to run the probe-echo skill.\n---\n\n# probe-echo\n\nWrite the word `echoed` into a file named `echo.txt` in the current directory. Then stop, do nothing else.\n", - "tool_use_id": "call_08lgEbj7vpYSXMq7xcV2m3Lt" -} diff --git a/scripts/__tests__/fixtures/codex-session-end.json b/scripts/__tests__/fixtures/codex-session-end.json deleted file mode 100644 index 229f94fa5..000000000 --- a/scripts/__tests__/fixtures/codex-session-end.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "session_id": "019fff53-5842-75a0-a9f2-c6cbd8ba0e03", - "transcript_path": "/home/user/probe/codex-home/sessions/2026/08/14/rollout-2026-08-14T10-11-20-019fff53-5842-75a0-a9f2-c6cbd8ba0e03.jsonl", - "cwd": "/home/user/probe/project-codex", - "hook_event_name": "SessionEnd", - "reason": "exit" -} diff --git a/scripts/__tests__/fixtures/codex-session-start.json b/scripts/__tests__/fixtures/codex-session-start.json deleted file mode 100644 index 3aace58bc..000000000 --- a/scripts/__tests__/fixtures/codex-session-start.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "session_id": "019fff53-5842-75a0-a9f2-c6cbd8ba0e03", - "transcript_path": "/home/user/probe/codex-home/sessions/2026/08/14/rollout-2026-08-14T10-11-20-019fff53-5842-75a0-a9f2-c6cbd8ba0e03.jsonl", - "cwd": "/home/user/probe/project-codex", - "hook_event_name": "SessionStart", - "model": "probe-stub", - "permission_mode": "bypassPermissions", - "source": "startup" -} diff --git a/scripts/__tests__/fixtures/codex-task-declared.json b/scripts/__tests__/fixtures/codex-task-declared.json deleted file mode 100644 index 0c3e68a61..000000000 --- a/scripts/__tests__/fixtures/codex-task-declared.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "session_id": "01a05850-9a3f-7c12-8e64-2b1d6f0a7c53", - "turn_id": "01a05850-a4e2-7d23-9f75-3c2e7a1b8d64", - "transcript_path": "/home/user/probe/codex-home-task/sessions/2026/08/31/rollout-2026-08-31T16-51-50-01a05850-9a3f-7c12-8e64-2b1d6f0a7c53.jsonl", - "cwd": "/home/user/probe/project-codex-task", - "hook_event_name": "PostToolUse", - "model": "gpt-5.5", - "permission_mode": "bypassPermissions", - "tool_name": "Bash", - "tool_input": { - "command": "sed -n '1,120p' aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md" - }, - "tool_response": "# alpha\n\nSpec body.\n", - "tool_use_id": "exec-4f8b2e6a-7c31-4d09-9a52-8e1f0c6b3d47" -} diff --git a/scripts/__tests__/fixtures/copilot-compat-post-tool-use-skill.json b/scripts/__tests__/fixtures/copilot-compat-post-tool-use-skill.json deleted file mode 100644 index 0d09800f4..000000000 --- a/scripts/__tests__/fixtures/copilot-compat-post-tool-use-skill.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "hook_event_name": "PostToolUse", - "session_id": "dc262588-c5be-4dc7-b2c9-91dfb1e9592b", - "timestamp": "2026-08-22T05:54:24.887Z", - "cwd": "/home/user/probe/project-copilot-compat-skill", - "tool_name": "skill", - "tool_input": { - "skill": "00-init" - }, - "tool_result": { - "result_type": "success", - "text_result_for_llm": "Skill \"00-init\" loaded successfully. Follow the instructions in the skill context." - } -} diff --git a/scripts/__tests__/fixtures/copilot-compat-post-tool-use.json b/scripts/__tests__/fixtures/copilot-compat-post-tool-use.json deleted file mode 100644 index bd6a5b3b3..000000000 --- a/scripts/__tests__/fixtures/copilot-compat-post-tool-use.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hook_event_name": "PostToolUse", - "session_id": "b1f8194a-7d49-484a-985e-21ff4c314dc1", - "timestamp": "2026-08-21T21:47:27.920Z", - "cwd": "/home/user/probe/project-copilot-compat", - "tool_name": "Bash", - "tool_input": { - "command": "ls -a .", - "description": "List all files (including hidden) in current directory" - }, - "tool_result": { - "result_type": "success", - "text_result_for_llm": ".\n..\n.aidd\n.git\n.github\n.gitignore\n" - } -} diff --git a/scripts/__tests__/fixtures/copilot-compat-pre-tool-use-skill.json b/scripts/__tests__/fixtures/copilot-compat-pre-tool-use-skill.json deleted file mode 100644 index 953e4722c..000000000 --- a/scripts/__tests__/fixtures/copilot-compat-pre-tool-use-skill.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "hook_event_name": "PreToolUse", - "session_id": "9f4a20c7-8e13-4d6b-b0a5-3c7e91d42f68", - "timestamp": "2026-08-28T03:20:14.425Z", - "cwd": "/home/user/probe/project-copilot-compat-skill", - "tool_name": "skill", - "tool_input": { - "skill": "probe-echo" - } -} diff --git a/scripts/__tests__/fixtures/copilot-compat-session-start.json b/scripts/__tests__/fixtures/copilot-compat-session-start.json deleted file mode 100644 index 7f1abc689..000000000 --- a/scripts/__tests__/fixtures/copilot-compat-session-start.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "hook_event_name": "SessionStart", - "session_id": "b1f8194a-7d49-484a-985e-21ff4c314dc1", - "timestamp": "2026-08-21T21:47:21.187Z", - "cwd": "/home/user/probe/project-copilot-compat", - "source": "new", - "initial_prompt": "Run: ls -a . Then say DONE." -} diff --git a/scripts/__tests__/fixtures/copilot-compat-turn-end.json b/scripts/__tests__/fixtures/copilot-compat-turn-end.json deleted file mode 100644 index 2c571ebc2..000000000 --- a/scripts/__tests__/fixtures/copilot-compat-turn-end.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "hook_event_name": "Stop", - "session_id": "b1f8194a-7d49-484a-985e-21ff4c314dc1", - "timestamp": "2026-08-21T21:47:30.132Z", - "cwd": "/home/user/probe/project-copilot-compat", - "transcript_path": "/home/user/probe/copilot-home/.copilot/session-state/b1f8194a-7d49-484a-985e-21ff4c314dc1/events.jsonl", - "stop_reason": "end_turn", - "stop_hook_active": false -} diff --git a/scripts/__tests__/fixtures/copilot-compat-user-prompt-submitted.json b/scripts/__tests__/fixtures/copilot-compat-user-prompt-submitted.json deleted file mode 100644 index 64520bad5..000000000 --- a/scripts/__tests__/fixtures/copilot-compat-user-prompt-submitted.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "hook_event_name": "UserPromptSubmit", - "session_id": "9f4a20c7-8e13-4d6b-b0a5-3c7e91d42f68", - "timestamp": "2026-08-28T03:20:06.479Z", - "cwd": "/home/user/probe/project-copilot-compat-skill", - "prompt": "Use the probe-echo skill, then reply with exactly what it tells you." -} diff --git a/scripts/__tests__/fixtures/copilot-post-tool-use-skill.json b/scripts/__tests__/fixtures/copilot-post-tool-use-skill.json deleted file mode 100644 index 9b7efd882..000000000 --- a/scripts/__tests__/fixtures/copilot-post-tool-use-skill.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "sessionId": "b349a8c0-d112-44a6-b329-cc46bdce4865", - "timestamp": 1787047151891, - "cwd": "/home/user/probe/project-copilot-skill", - "toolName": "skill", - "toolArgs": "{\"skill\":\"probe-echo\"}", - "toolResult": { - "resultType": "success", - "textResultForLlm": "Skill \"probe-echo\" loaded successfully. Follow the instructions in the skill context." - } -} diff --git a/scripts/__tests__/fixtures/copilot-session-start.json b/scripts/__tests__/fixtures/copilot-session-start.json deleted file mode 100644 index 8c0604c4e..000000000 --- a/scripts/__tests__/fixtures/copilot-session-start.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "sessionId": "5b1d40a6-2b18-43bf-aeb4-cba65cb1780c", - "timestamp": 1786695240971, - "cwd": "/home/user/probe/project-copilot", - "source": "new", - "initialPrompt": "reply with the single word ok" -} diff --git a/scripts/__tests__/fixtures/copilot-task-declared.json b/scripts/__tests__/fixtures/copilot-task-declared.json deleted file mode 100644 index 3d53f0cc2..000000000 --- a/scripts/__tests__/fixtures/copilot-task-declared.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "hook_event_name": "PostToolUse", - "session_id": "1fb7f27d-2204-4612-8704-814d9d147cf1", - "timestamp": "2026-08-31T09:57:06.733Z", - "cwd": "/home/user/probe/project-copilot-task", - "tool_name": "Read", - "tool_input": { - "path": "/home/user/probe/project-copilot-task/aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md" - }, - "tool_result": { - "result_type": "success", - "text_result_for_llm": "# alpha\n\nSpec body.\n" - } -} diff --git a/scripts/__tests__/fixtures/cursor-post-tool-use-skill-read.json b/scripts/__tests__/fixtures/cursor-post-tool-use-skill-read.json deleted file mode 100644 index f4252cac9..000000000 --- a/scripts/__tests__/fixtures/cursor-post-tool-use-skill-read.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "conversation_id": "016b6a9e-920e-4560-b1e2-5024bbfcef80", - "generation_id": "016b6a9e-920e-4560-b1e2-5024bbfcef80", - "model": "default", - "tool_name": "Read", - "tool_input": { - "file_path": "/home/user/probe/project-cursor-skill/.cursor/skills/probe-echo/SKILL.md" - }, - "tool_output": "{\"file_path\":\"/home/user/probe/project-cursor-skill/.cursor/skills/probe-echo/SKILL.md\",\"content_length\":251}", - "duration": 352.413, - "tool_use_id": "call_Pcihf51QkJJPVrqHheQ3X4fA", - "session_id": "016b6a9e-920e-4560-b1e2-5024bbfcef80", - "hook_event_name": "postToolUse", - "cursor_version": "2026.08.11-e8db854", - "workspace_roots": ["/home/user/probe/project-cursor-skill"] -} diff --git a/scripts/__tests__/fixtures/cursor-post-tool-use.json b/scripts/__tests__/fixtures/cursor-post-tool-use.json deleted file mode 100644 index 56cdd24ae..000000000 --- a/scripts/__tests__/fixtures/cursor-post-tool-use.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "conversation_id": "016b6a9e-920e-4560-b1e2-5024bbfcef80", - "generation_id": "016b6a9e-920e-4560-b1e2-5024bbfcef80", - "model": "default", - "tool_name": "Read", - "tool_input": { - "file_path": "/home/user/probe/project-cursor-skill/.cursor/skills/probe-echo/SKILL.md" - }, - "tool_output": "{\"file_path\":\"/home/user/probe/project-cursor-skill/.cursor/skills/probe-echo/SKILL.md\",\"content_length\":251}", - "duration": 352.413, - "tool_use_id": "call_Pcihf51QkJJPVrqHheQ3X4fA\nfc_07841c621464695d016a842dabe1448195aaa891093db23e60", - "session_id": "016b6a9e-920e-4560-b1e2-5024bbfcef80", - "hook_event_name": "postToolUse", - "cursor_version": "2026.08.11-e8db854", - "workspace_roots": [ - "/home/user/probe/project-cursor-skill" - ], - "user_email": "user@example.com", - "transcript_path": "/home/user/.cursor/projects/project-cursor-skill/agent-transcripts/016b6a9e-920e-4560-b1e2-5024bbfcef80/016b6a9e-920e-4560-b1e2-5024bbfcef80.jsonl" -} diff --git a/scripts/__tests__/fixtures/cursor-session-start.json b/scripts/__tests__/fixtures/cursor-session-start.json deleted file mode 100644 index ecd80647a..000000000 --- a/scripts/__tests__/fixtures/cursor-session-start.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "conversation_id": "7059918f-ce9d-49ed-a33f-0f1906a79f27", - "generation_id": "7059918f-ce9d-49ed-a33f-0f1906a79f27", - "model": "default", - "is_background_agent": false, - "session_id": "7059918f-ce9d-49ed-a33f-0f1906a79f27", - "hook_event_name": "sessionStart", - "cursor_version": "2026.08.11-e8db854", - "workspace_roots": [ - "/home/user/probe/project-cursor" - ], - "user_email": "user@example.com", - "transcript_path": null -} diff --git a/scripts/__tests__/fixtures/cursor-task-declared.json b/scripts/__tests__/fixtures/cursor-task-declared.json deleted file mode 100644 index 8e6ddf56d..000000000 --- a/scripts/__tests__/fixtures/cursor-task-declared.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "conversation_id": "10307932-49df-427b-95dc-a2a85ee02b9a", - "generation_id": "10307932-49df-427b-95dc-a2a85ee02b9a", - "model": "default", - "tool_name": "Read", - "tool_input": { - "file_path": "/home/user/probe/project-cursor-task/aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md" - }, - "tool_output": "{\"file_path\":\"/home/user/probe/project-cursor-task/aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md\",\"content_length\":20}", - "duration": 365.266, - "tool_use_id": "tool_91afb302-3888-4284-8705-1098d3c2f24", - "session_id": "10307932-49df-427b-95dc-a2a85ee02b9a", - "hook_event_name": "postToolUse", - "cursor_version": "2026.08.11-e8db854", - "workspace_roots": ["/home/user/probe/project-cursor-task"], - "user_email": "user@example.com", - "transcript_path": "/home/user/probe/cursor-home-task/projects/project-cursor-task/agent-transcripts/10307932-49df-427b-95dc-a2a85ee02b9a/10307932-49df-427b-95dc-a2a85ee02b9a.jsonl" -} diff --git a/scripts/__tests__/fixtures/opencode-session-created.json b/scripts/__tests__/fixtures/opencode-session-created.json deleted file mode 100644 index c67600f09..000000000 --- a/scripts/__tests__/fixtures/opencode-session-created.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "type": "session.created", - "properties": { - "info": { - "id": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", - "slug": "synthetic-session", - "projectID": "global", - "directory": "/home/user/probe/project", - "title": "New session - 2026-08-31T00:00:00.000Z", - "version": "1.14.20", - "permission": [ - { "permission": "question", "pattern": "*", "action": "deny" }, - { "permission": "plan_enter", "pattern": "*", "action": "deny" }, - { "permission": "plan_exit", "pattern": "*", "action": "deny" } - ], - "time": { "created": 1788134400000, "updated": 1788134400000 } - } - } -} diff --git a/scripts/__tests__/fixtures/opencode-task-declared.json b/scripts/__tests__/fixtures/opencode-task-declared.json deleted file mode 100644 index c251f1ae8..000000000 --- a/scripts/__tests__/fixtures/opencode-task-declared.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "tool": "opencode", - "session_id": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", - "cwd": "/home/user/probe/project-opencode-task", - "tool_input": { - "filePath": "/home/user/probe/project-opencode-task/aidd_docs/tasks/2026_08/2026_08_15_alpha/spec.md" - } -} diff --git a/scripts/__tests__/fixtures/opencode-tool-part-no-task.json b/scripts/__tests__/fixtures/opencode-tool-part-no-task.json deleted file mode 100644 index 12ffbc95a..000000000 --- a/scripts/__tests__/fixtures/opencode-tool-part-no-task.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "type": "message.part.updated", - "properties": { - "sessionID": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", - "part": { - "id": "prt_cccccccccccccccccccccccc", - "messageID": "msg_bbbbbbbbbbbbbbbbbbbbbbbbbb", - "sessionID": "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", - "type": "tool", - "tool": "bash", - "callID": "call_cccccccccccccccccccccc", - "state": { - "status": "completed", - "input": { - "command": "ls -la", - "description": "List all files in current directory", - "workdir": "/home/user/probe/project-opencode-task" - }, - "output": ".git/\n.opencode/\nnote.txt\n", - "metadata": { - "output": ".git/\n.opencode/\nnote.txt\n", - "description": "List all files in current directory" - }, - "title": "List all files in current directory", - "time": { "start": 1788189156930, "end": 1788189156932 } - } - }, - "time": 1788189156932 - } -} diff --git a/scripts/__tests__/opencode-plugin.test.js b/scripts/__tests__/opencode-plugin.test.js deleted file mode 100644 index d1b6694cc..000000000 --- a/scripts/__tests__/opencode-plugin.test.js +++ /dev/null @@ -1,321 +0,0 @@ -const assert = require("node:assert/strict"); -const { execFileSync } = require("node:child_process"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { pathToFileURL } = require("node:url"); -const test = require("node:test"); - -const PLUGIN_SOURCE = path.resolve( - __dirname, - "../../plugins/aidd-telemetry/hooks/opencode-plugin.js" -); - -const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")) -); - -// Removed when the file finishes: a suite that seeds a repository per run and never sweeps -// fills the machine's temp volume until mkdtemp itself fails with ENOSPC. -const tempDirs = []; -test.after(() => { - for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); -}); - -function makeTempDir(prefix) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -// Mirrors what a real install delivers: OpenCode's loader scans `plugin/` one level deep, so -// the build puts this plugin's own module there alone, renamed after the plugin, and every -// other hook script under `hooks//` (opencode-paths.ts) - not the source tree, where -// they are siblings, so this exercises the split layout the loader actually sees. -function makeInstalledRepo() { - const repo = makeTempDir("aidd-opencode-plugin-repo-"); - execFileSync("git", ["init", "-q"], { cwd: repo, env: CLEAN_ENV }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repo, env: CLEAN_ENV }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: repo, env: CLEAN_ENV }); - fs.mkdirSync(path.join(repo, "aidd_docs", "runs"), { recursive: true }); - fs.mkdirSync(path.join(repo, ".aidd"), { recursive: true }); - fs.writeFileSync( - path.join(repo, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true, endpoint: "http://127.0.0.1:4318" } }) - ); - const pluginDir = path.join(repo, ".opencode", "plugin"); - const scriptsDir = path.join(repo, ".opencode", "hooks", "aidd-telemetry"); - fs.mkdirSync(pluginDir, { recursive: true }); - fs.mkdirSync(scriptsDir, { recursive: true }); - fs.copyFileSync( - path.resolve(__dirname, "../../cli/assets/configs/opencode/opencode-events.js.txt"), - path.join(repo, ".opencode", "hooks", "opencode-events.js") - ); - const hooksSrc = path.dirname(PLUGIN_SOURCE); - for (const entry of fs.readdirSync(hooksSrc, { withFileTypes: true })) { - if (entry.name === "hooks.json") continue; - const loaderEntry = entry.name === path.basename(PLUGIN_SOURCE); - const target = loaderEntry - ? path.join(pluginDir, "aidd-telemetry.js") - : path.join(scriptsDir, entry.name); - fs.cpSync(path.join(hooksSrc, entry.name), target, { recursive: true }); - } - // A byte-identical `.mjs` twin, for these tests alone. - // - // An install carries the plugin as `.js`, forced rather than chosen: OpenCode auto-discovers - // `{plugin,plugins}/*.{ts,js}` and nothing else, and loads the file with its own runtime, - // which does not consult Node's `type` field. - // - // Plain Node does consult it, and there is none to consult: nothing up this tree declares - // one, so Node reaches the file as typeless, finds ESM syntax, and reparses. Naming the - // extension explicitly is what these tests do instead, and it is the only difference. - const esmTwin = path.join(pluginDir, "aidd-telemetry.mjs"); - fs.copyFileSync(path.join(pluginDir, "aidd-telemetry.js"), esmTwin); - return { repo, pluginDir, esmTwin }; -} - -function runsDirOf(repo) { - return path.join(repo, "aidd_docs", "runs"); -} - -function readRunLines(repo) { - const dir = runsDirOf(repo); - const files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")); - return files.flatMap((f) => - fs - .readFileSync(path.join(dir, f), "utf8") - .split("\n") - .filter(Boolean) - .map((l) => JSON.parse(l)) - ); -} - -test("opencode-plugin.js: runJournal spawns journal.cjs by an absolute filesystem path, not a file:// URL string", async () => { - // `spawnSync("node", [new URL(...)])` stringifies the URL to "file:///...", which node's - // CLI resolves as a bare module specifier against its own cwd and dies with - // MODULE_NOT_FOUND. journal.cjs then never runs, and its "exit 0 no matter what" contract - // hides the spawn failure entirely. - const { repo, esmTwin } = makeInstalledRepo(); - const mod = await import(pathToFileURL(esmTwin).href); - - const hooks = await mod.AiddTelemetry({ directory: repo }); - await hooks.event({ - event: { - type: "session.created", - properties: { info: { id: "ses_test1234567890", directory: repo } }, - }, - }); - - const lines = readRunLines(repo); - assert.equal(lines.length, 1, "expected one session_start line written by journal.cjs"); - assert.equal(lines[0].type, "session_start"); - assert.equal(lines[0].tool, "opencode"); - assert.equal(lines[0].vendor_id, "ses_test1234567890"); -}); - -test("opencode-plugin.js: session.idle writes turn_end for the session session.created named", async () => { - const { repo, esmTwin } = makeInstalledRepo(); - const mod = await import(pathToFileURL(esmTwin).href); - - const hooks = await mod.AiddTelemetry({ directory: repo }); - await hooks.event({ - event: { - type: "session.created", - properties: { info: { id: "ses_test_idle", directory: repo } }, - }, - }); - await hooks.event({ - event: { type: "session.idle", properties: { sessionID: "ses_test_idle" } }, - }); - - const lines = readRunLines(repo); - assert.deepEqual( - lines.map((l) => l.type), - ["session_start", "turn_end"] - ); -}); - -test("opencode-plugin.js: an event whose own shape breaks journal call resolution never reaches OpenCode as a thrown error", async () => { - const { repo, esmTwin } = makeInstalledRepo(); - const mod = await import(pathToFileURL(esmTwin).href); - - const hooks = await mod.AiddTelemetry({ directory: repo }); - - // `event: null` is not a shape any fixture or the SDK's own types describe - exactly the - // kind of malformed input the plugin's own event handler must swallow rather than throw - // into OpenCode's in-process event loop. - await assert.doesNotReject(hooks.event({ event: null })); - assert.deepEqual(readRunLines(repo), [], "a swallowed error must write no journal line either"); -}); - -test("opencode-plugin.js: default server preserves V1 journal writes", async () => { - const { repo, esmTwin } = makeInstalledRepo(); - const { default: plugin } = await import(pathToFileURL(esmTwin).href); - - assert.equal(plugin.id, "aidd-telemetry"); - assert.deepEqual(readRunLines(repo), []); - const hooks = await plugin.server({ directory: repo }); - await hooks.event({ - event: { type: "session.idle", properties: { sessionID: "ses_default_v1" } }, - }); - - assert.deepEqual( - readRunLines(repo).map((line) => line.type), - ["session_start", "turn_end"] - ); -}); - -test("opencode-plugin.js: V2 setup returns promptly, handles raw events and cancels its stream", { - timeout: 5000, -}, async () => { - const { repo, esmTwin } = makeInstalledRepo(); - const { default: plugin } = await import(pathToFileURL(esmTwin).href); - let signal; - let streamClosed = false; - const cleanup = await plugin.setup({ - location: { directory: repo }, - event: { - subscribe(options) { - signal = options.signal; - return (async function* () { - try { - yield null; - yield { type: "server.connected" }; - yield { - type: "session.created", - data: { sessionID: "ses_default_v2", location: { directory: repo } }, - }; - const call = { sessionID: "ses_default_v2", assistantMessageID: "msg_1", id: "call_1" }; - const failed = { ...call, id: "call_failed" }; - yield { type: "session.tool.input.started", data: { ...failed, name: "read" } }; - yield { - type: "session.tool.called", - data: { - ...failed, - input: { path: "aidd_docs/tasks/2026_10/failed/plan.md" }, - executed: false, - }, - }; - yield { type: "session.tool.failed", data: { ...failed, error: "read failed" } }; - yield { - type: "session.tool.success", - data: { ...failed, content: [], executed: false }, - }; - yield { type: "session.tool.input.started", data: { ...call, name: "read" } }; - yield { - type: "session.tool.called", - data: { - ...call, - input: { path: "aidd_docs/tasks/2026_10/plugin-loading/plan.md" }, - executed: false, - }, - }; - yield { - type: "session.tool.success", - data: { ...call, content: [{ type: "text", text: "task" }], executed: false }, - }; - yield { - type: "session.tool.success", - data: { ...call, content: [{ type: "text", text: "task" }], executed: false }, - }; - yield { type: "session.execution.succeeded", data: { sessionID: "ses_default_v2" } }; - await new Promise((resolve) => - signal.addEventListener("abort", resolve, { once: true }) - ); - throw new Error("subscription aborted"); - } finally { - streamClosed = true; - } - })(); - }, - }, - }); - - assert.equal(typeof cleanup, "function"); - for (let attempt = 0; attempt < 100 && readRunLines(repo).length < 3; attempt++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - const lines = readRunLines(repo); - assert.deepEqual( - lines.map((line) => line.type), - ["session_start", "task_declared", "turn_end"] - ); - assert.equal(lines[0].vendor_id, "ses_default_v2"); - assert.equal(lines[1].path, "aidd_docs/tasks/2026_10/plugin-loading/plan.md"); - cleanup(); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(signal.aborted, true); - assert.equal(streamClosed, true); -}); - -test("opencode-plugin.js: a failed V2 subscription never rejects into the host", async () => { - const { repo, esmTwin } = makeInstalledRepo(); - const { default: plugin } = await import(pathToFileURL(esmTwin).href); - let signal; - const cleanup = await plugin.setup({ - location: { directory: repo }, - event: { - subscribe: async function* (options) { - signal = options.signal; - yield { type: "server.connected" }; - throw new Error("stream unavailable"); - }, - }, - }); - - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(signal.aborted, true); - cleanup(); - assert.deepEqual(readRunLines(repo), []); -}); - -test("opencode-plugin.js: V2 failed and interrupted turns end on their own session, shutdown leaves it resumable", async () => { - for (const [type, reason, expected] of [ - ["session.execution.failed", undefined, ["session_start", "turn_end"]], - ["session.execution.interrupted", "user", ["session_start", "turn_end"]], - ["session.execution.interrupted", "shutdown", ["session_start"]], - ]) { - const { repo, esmTwin } = makeInstalledRepo(); - const { default: plugin } = await import(pathToFileURL(esmTwin).href); - let done; - const streamed = new Promise((resolve) => { - done = resolve; - }); - const cleanup = await plugin.setup({ - location: { directory: repo }, - event: { - subscribe: async function* () { - yield { - type: "session.created", - data: { sessionID: "ses_child", location: { directory: repo } }, - }; - const call = { - sessionID: "ses_child", - assistantMessageID: "msg_pending", - id: "call_pending", - }; - yield { type: "session.tool.input.started", data: { ...call, name: "read" } }; - yield { - type: "session.tool.called", - data: { - ...call, - input: { path: "aidd_docs/tasks/2026_10/stale/plan.md" }, - executed: false, - }, - }; - yield { type, data: { sessionID: "ses_child", reason, error: "tool failed" } }; - yield { type: "session.tool.success", data: { ...call, content: [], executed: false } }; - done(); - }, - }, - }); - await streamed; - cleanup(); - const lines = readRunLines(repo); - assert.deepEqual( - lines.map((line) => line.type), - expected - ); - assert.equal(lines[0].vendor_id, "ses_child"); - } -}); diff --git a/scripts/__tests__/plugin-install-shape.test.js b/scripts/__tests__/plugin-install-shape.test.js deleted file mode 100644 index 5371ce4ef..000000000 --- a/scripts/__tests__/plugin-install-shape.test.js +++ /dev/null @@ -1,36 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const { describe, it } = require("node:test"); - -const SKILLS_DIR = path.resolve(__dirname, "../../plugins/aidd-telemetry/skills"); - -// Walks each skill's scripts directory one level deep, so a skill's `scripts/lib/` -// internals (only ever require()d, never run directly) are left for the scripts that -// load them to cover. -function discoverScripts(skillsRoot) { - const found = []; - for (const skillEntry of fs.readdirSync(skillsRoot, { withFileTypes: true })) { - if (!skillEntry.isDirectory()) continue; - const scriptsDir = path.join(skillsRoot, skillEntry.name, "scripts"); - if (!fs.existsSync(scriptsDir)) continue; - for (const fileEntry of fs.readdirSync(scriptsDir, { withFileTypes: true })) { - if (fileEntry.isFile() && fileEntry.name.endsWith(".cjs")) { - found.push(`${skillEntry.name}/scripts/${fileEntry.name}`); - } - } - } - return found.sort(); -} - -/** - * A skill in this plugin ships no script of its own, every one of them calling `aidd` - * instead. A harness that built the install shape four ways and ran each skill's scripts - * under it proved nothing once there were no scripts left to find, since the shape changes - * only where `skills/` sits and never what is inside it. - */ -describe("the plugin ships no skill scripts, now that every skill calls the CLI instead", () => { - it("no skill's scripts/ directory holds a .cjs file", () => { - assert.deepEqual(discoverScripts(SKILLS_DIR), []); - }); -}); diff --git a/scripts/__tests__/telemetry-cli-required.test.js b/scripts/__tests__/telemetry-cli-required.test.js deleted file mode 100644 index 6c35144f7..000000000 --- a/scripts/__tests__/telemetry-cli-required.test.js +++ /dev/null @@ -1,79 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const { describe, it } = require("node:test"); - -const ROOT = path.resolve(__dirname, "../.."); -const PLUGIN = path.join(ROOT, "plugins/aidd-telemetry"); - -/** - * The three places this plugin requires `aidd` to answer, each the first numbered step of its - * own action file. A fourth skill, or a fourth wording, is what this guards against. - */ -const LOCATING_ACTIONS = [ - "skills/00-init/actions/01-check.md", - "skills/01-cost/actions/01-locate.md", - "skills/02-check/actions/01-locate.md", -]; - -const BLOCK_START = "No output, or a command that is not found"; -const BLOCK_END = "tool that cost nothing."; - -/** The absent-CLI wording alone: what a skill says once `aidd --version` comes back empty - * or unresolved, from the sentence that names the condition through the sentence that - * forbids continuing. Not the whole action - the opening line naming what answering does - * is allowed to differ per skill, since each skill answers something different. */ -function absentCliWording(relativePath) { - const text = fs.readFileSync(path.join(PLUGIN, relativePath), "utf8"); - const start = text.indexOf(BLOCK_START); - const end = text.indexOf(BLOCK_END); - assert.ok(start !== -1, `${relativePath} must name the absent-CLI condition`); - assert.ok(end !== -1, `${relativePath} must forbid continuing to a report`); - return text.slice(start, end + BLOCK_END.length); -} - -describe("every skill that needs the CLI says so the same way", () => { - it("the absent-CLI wording is identical, character for character, across all three", () => { - const [first, ...rest] = LOCATING_ACTIONS.map(absentCliWording); - for (const [index, wording] of rest.entries()) { - assert.equal(wording, first, `${LOCATING_ACTIONS[index + 1]} must match ${LOCATING_ACTIONS[0]} exactly`); - } - }); - - it("the wording itself forbids reaching a report", () => { - for (const relativePath of LOCATING_ACTIONS) { - const wording = absentCliWording(relativePath); - assert.match(wording, /\*\*Stop, and\b/u, `${relativePath} must stop unconditionally`); - assert.match( - wording, - /Never continue to a report/u, - `${relativePath} must forbid continuing to a report` - ); - } - }); - - it("requiring the CLI is the first thing each action does, not a later branch", () => { - for (const relativePath of LOCATING_ACTIONS) { - const text = fs.readFileSync(path.join(PLUGIN, relativePath), "utf8"); - const processIdx = text.indexOf("## Process"); - const firstStepIdx = text.indexOf("1. **Require the CLI.**"); - const blockIdx = text.indexOf(BLOCK_START); - assert.ok(processIdx !== -1 && firstStepIdx !== -1, `${relativePath} must open with "Require the CLI"`); - assert.ok( - processIdx < firstStepIdx && firstStepIdx < blockIdx, - `${relativePath} must check the CLI before anything else in its Process` - ); - } - }); - - it("recording is stated as unaffected wherever the CLI is named as required", () => { - for (const relativePath of LOCATING_ACTIONS) { - const wording = absentCliWording(relativePath); - assert.match( - wording, - /recording is unaffected.*hooks that write the journal are plain node/su, - `${relativePath} must say recording keeps working without the CLI` - ); - } - }); -}); diff --git a/scripts/__tests__/telemetry-where-things-live.test.js b/scripts/__tests__/telemetry-where-things-live.test.js deleted file mode 100644 index 3db677bf0..000000000 --- a/scripts/__tests__/telemetry-where-things-live.test.js +++ /dev/null @@ -1,130 +0,0 @@ -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const path = require("node:path"); -const { describe, it } = require("node:test"); - -// A killed run never fires its `after` hooks, so its temp directories survive. Swept here at -// module load rather than in a hook: the age bound in the sweep means it can only ever touch -// a directory from an earlier run, so it is safe wherever in the order this file lands. -require("../sweep-stale-test-dirs.cjs").sweepStaleTestDirs(); - -const ROOT = path.resolve(__dirname, "../.."); - -/** Where the figures land is pinned in - * cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts, since - * the sink that writes them now lives only in the CLI. What is left here is about the - * plugin's own shape: what each skill carries, and that nothing reaches across. */ - -describe("a library a skill needs is carried by that skill, identically", () => { - // No boundary walk over skill scripts: `plugin-install-shape.test.js` already pins that a - // skill carries none, and a check with nothing to walk cannot fail on any input. - - /** No `package.json` marker anywhere: every CommonJS file this plugin ships is named - * `.cjs`, which Node reads as CommonJS whatever the host project declares. A marker is a - * property of a directory and a rename can move a file out from under it; an extension - * travels with the file. The one exception is the ESM entry OpenCode discovers by glob - * (`{plugin,plugins}/*.{ts,js}`), which must stay `.js` and is genuine ESM anyway. */ - it("declares its module system per file, so no directory marker can be lost", () => { - const PLUGIN = path.join(ROOT, "plugins/aidd-telemetry"); - const scripts = []; - const walk = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (/\.(js|cjs|mjs)$/u.test(entry.name)) - scripts.push(path.relative(PLUGIN, full).split(path.sep).join("/")); - else if (entry.name === "package.json" && dir !== PLUGIN) { - assert.fail(`${path.relative(PLUGIN, full)}: no directory marker, name the file .cjs`); - } - } - }; - walk(PLUGIN); - assert.deepEqual( - scripts.filter((f) => f.endsWith(".js")), - ["hooks/opencode-plugin.js"] - ); - }); -}); - -/** - * Every script path this repository names must exist. `plugin-install-shape.test.js` walks the - * scripts that exist and cannot see a reference to one that does not; `check-markdown-links.js` - * walks markdown links and cannot see a command inside a fenced block. Between those two, the - * README went on naming `telemetry-report.cjs` and a workflow went on running - * `telemetry-switch.cjs` after both were deleted. - * - * This inverts the walk: start from what is written down, and require the file. - */ -describe("a script path this repository names is a script that exists", () => { - const ROOT_DIR = path.resolve(__dirname, "../.."); - const PLUGIN_DIR = path.join(ROOT_DIR, "plugins/aidd-telemetry"); - const SEARCHED = ["plugins/aidd-telemetry", "docs", ".github/workflows", "README.md"]; - - // Only the two forms anyone actually runs - repo-rooted, and the README's `/…` form. - // A bare fragment in prose describing a layout is not a reference, and asserting it would - // make this guard cry wolf. `scripts` is deliberately absent: it is both a repository - // directory and the conventional subdirectory inside every skill, so - // `scripts/telemetry-check.cjs` in a skill's own markdown is relative to that skill and - // resolves nowhere from here. - const REPO_ROOTED = /(?:^|[\s"'`(])((?:plugins|cli|docs)\/[\w./-]+\.(?:cjs|mjs))/gmu; - const PLUGIN_ROOTED = /\/([\w./-]+\.(?:cjs|mjs))/gmu; - - function textFiles(target) { - const full = path.join(ROOT_DIR, target); - if (!fs.existsSync(full)) return []; - if (fs.statSync(full).isFile()) return [full]; - const found = []; - const walk = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const child = path.join(dir, entry.name); - if (entry.isDirectory()) walk(child); - else if (/\.(md|ya?ml)$/u.test(entry.name)) found.push(child); - } - }; - walk(full); - return found; - } - - function missingReferences() { - const missing = []; - for (const target of SEARCHED) { - for (const file of textFiles(target)) { - const text = fs.readFileSync(file, "utf8"); - const where = path.relative(ROOT_DIR, file); - for (const match of text.matchAll(REPO_ROOTED)) { - if (!fs.existsSync(path.join(ROOT_DIR, match[1]))) { - missing.push(`${where} names ${match[1]}`); - } - } - for (const match of text.matchAll(PLUGIN_ROOTED)) { - if (!fs.existsSync(path.join(PLUGIN_DIR, match[1]))) { - missing.push(`${where} names /${match[1]}`); - } - } - } - } - return missing; - } - - it("names no script that has been deleted", () => { - assert.deepEqual(missingReferences(), []); - }); - - it("detects a named-but-absent script, on both forms", () => { - // The guard's own proof: run the two patterns over text that names files which do not - // exist, and require both to be reported. Asserting only that the two real scripts are - // gone would pass even if the detection below were broken. - const planted = [ - "run: node plugins/aidd-telemetry/skills/00-init/scripts/telemetry-switch.cjs on", - "node /skills/01-cost/scripts/telemetry-report.cjs read", - ].join("\n"); - - const repoRooted = [...planted.matchAll(REPO_ROOTED)].map((match) => match[1]); - const pluginRooted = [...planted.matchAll(PLUGIN_ROOTED)].map((match) => match[1]); - - assert.equal(repoRooted.length, 1, "the repo-rooted form must be seen"); - assert.equal(pluginRooted.length, 1, "the -rooted form must be seen"); - assert.ok(!fs.existsSync(path.join(ROOT_DIR, repoRooted[0] ?? ""))); - assert.ok(!fs.existsSync(path.join(PLUGIN_DIR, pluginRooted[0] ?? ""))); - }); -}); diff --git a/scripts/check-claude-accepts-build.cjs b/scripts/check-claude-accepts-build.cjs index 41a120c90..8e7fe05b7 100644 --- a/scripts/check-claude-accepts-build.cjs +++ b/scripts/check-claude-accepts-build.cjs @@ -6,7 +6,7 @@ * `claude plugin validate` exits 0 whether validation passed or failed (measured on 2.1.x), * so the verdict is read from its text, never from its exit code. * - * Exit codes, same contract as `probe-identifier-join.cjs`: + * Exit codes: * 0 the host accepts the build * 1 the host refuses it * 2 no verdict — the CLI is not built, the translation failed, or the host said neither diff --git a/scripts/check-doc-duplication.js b/scripts/check-doc-duplication.js index fcb98a0a0..7adc3d019 100644 --- a/scripts/check-doc-duplication.js +++ b/scripts/check-doc-duplication.js @@ -25,9 +25,6 @@ const SCANNED_DIRECTORIES = ["docs", "aidd_docs/memory", "cli/aidd_docs/memory"] const SCANNED_FILES = ["README.md", "cli/README.md"]; const SCANNED_GLOBS = ["plugins/*/README.md"]; -/** Generated on every commit by lefthook, so a duplicate here is the generator's, not a page's. */ -const GENERATED = new Set(["docs/prompts-documentation.md"]); - const MINIMUM_WORDS = 12; const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n/u; @@ -154,7 +151,7 @@ function scannedFiles(root) { ].map((file) => file.split(path.sep).join("/")); // CATALOG.md is regenerated from the tree it indexes, so its lines are an output. - return found.filter((file) => !GENERATED.has(file) && path.basename(file) !== "CATALOG.md"); + return found.filter((file) => path.basename(file) !== "CATALOG.md"); } function scanRepository(root = process.cwd()) { diff --git a/scripts/lib/frozen-clock.cjs b/scripts/lib/frozen-clock.cjs deleted file mode 100644 index 2942430ef..000000000 --- a/scripts/lib/frozen-clock.cjs +++ /dev/null @@ -1,24 +0,0 @@ -// Preloaded with `node --require` in front of the journal hook, nowhere else. -// -// `record.cjs`'s `nowIso()` reads the wall clock, so a three-day scenario run in one second -// would collapse every interval onto one instant and stay green while doing it. -// -// `Date.now()` moves with the constructor: `record.cjs`'s ULID takes its prefix from it. -const frozen = process.env.AIDD_FROZEN_CLOCK; -if (frozen) { - const at = new Date(frozen); - if (Number.isNaN(at.getTime())) { - throw new Error(`AIDD_FROZEN_CLOCK is not an instant this can read: ${frozen}`); - } - const fixed = at.getTime(); - class FrozenDate extends Date { - constructor(...args) { - // Only the argument-less call asks the time; `new Date(x)` is a parse. - super(...(args.length === 0 ? [fixed] : args)); - } - static now() { - return fixed; - } - } - globalThis.Date = FrozenDate; -} diff --git a/scripts/lib/telemetry-reference-week.cjs b/scripts/lib/telemetry-reference-week.cjs deleted file mode 100644 index 11f37e047..000000000 --- a/scripts/lib/telemetry-reference-week.cjs +++ /dev/null @@ -1,520 +0,0 @@ -/** - * One week of work, measured — the scenario this repository points at when it claims the - * telemetry core answers a period: two people on two machines, two projects, three tools, - * three days, one SDLC flow, one task that declares its backlog item and one that does not. - * - * Real are the journal, written by the shipped hook spawned with a payload on stdin, and the - * reading — sink, identity, `telemetry read` and `telemetry report` through the built CLI. - * Authored are every session file (shapes taken from `cli/tests/fixtures/local-cost/`), the - * task folders, the git remotes, the model ids, the counters and the clock. So this proves - * capture-to-analysis over authored inputs, never that a tool writes what is claimed here. - * - * `AIDD_TELEMETRY_DIR` moves the figures and `AIDD_RUNS_DIR` the journal, while identity - * refuses both and resolves from `HOME` — which is what makes two sandboxed homes two people - * landing in one destination. Not `AIDD_USER_CONFIG_DIR`, which would work and would also - * relocate `auth.json`. - * - * No amount appears anywhere and none is pending: `supplies.amount` is `false` on all five - * tools and the framework holds no price table, so a week has counters and no currency. - */ - -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const path = require("node:path"); - -const REPO_ROOT = path.resolve(__dirname, "..", ".."); -const HOOK = path.join(REPO_ROOT, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); -const FROZEN_CLOCK = path.join(__dirname, "frozen-clock.cjs"); -const CLI = path.join(REPO_ROOT, "cli", "dist", "cli.js"); - -const PERIOD = Object.freeze({ from: "2026-03-01", to: "2026-03-07" }); - -const PEOPLE = Object.freeze({ - ada: Object.freeze({ personId: "person-ada", displayName: "Ada" }), - bo: Object.freeze({ personId: "person-bo", displayName: "Bo" }), -}); - -const PROJECTS = Object.freeze({ - widgets: Object.freeze({ remote: "git@github.com:acme/widgets.git" }), - gadgets: Object.freeze({ remote: "git@github.com:acme/gadgets.git" }), -}); - -const FLOW_SKILL = "aidd-orchestrator:01-sdlc"; -const TASK_WITH_BACKLOG = "2026_03/2026_03_02_gouvernail"; -const TASK_WITHOUT_BACKLOG = "2026_03/2026_03_03_pricing"; -const BACKLOG_ITEM = "#661"; - -// UUID-shaped because Codex parses its rollout file name back into one. Fixed digits: this -// is a public repository and nothing here came off a real session. -const SESSION = Object.freeze({ - adaFlow: "aaaaaaa1-0000-4000-8000-000000000001", - adaCodex: "aaaaaaa2-0000-4000-8000-000000000002", - boClaude: "bbbbbbb3-0000-4000-8000-000000000003", - boCodex: "bbbbbbb4-0000-4000-8000-000000000004", - boCopilot: "bbbbbbb5-0000-4000-8000-000000000005", -}); - -/** Stated once so the test asserts it and the script prints against the same list. */ -const EXPECTED = Object.freeze({ - requests: 7, - inputTokens: 700, - outputTokens: 70, - totalTokens: 1060, - days: Object.freeze(["2026-03-02", "2026-03-03", "2026-03-04"]), - models: Object.freeze(["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5", "gpt-5.5"]), - requestTools: Object.freeze(["claude", "codex"]), - sessionOnlyTool: "copilot", - uncoveredTools: Object.freeze(["cursor", "opencode"]), - projects: Object.freeze([PROJECTS.widgets.remote, PROJECTS.gadgets.remote]), - people: Object.freeze([PEOPLE.ada.personId, PEOPLE.bo.personId]), - flow: FLOW_SKILL, - tasks: Object.freeze([TASK_WITH_BACKLOG, TASK_WITHOUT_BACKLOG]), - backlogItem: BACKLOG_ITEM, -}); - -/** Git exports `GIT_DIR` and friends into every process it spawns, so anything run from - * inside a git hook inherits a pointer to the *real* repository: the checkouts below would - * `git init` a temp directory and then fail on `git remote add origin`. */ -function withoutGitVariables(env) { - return Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith("GIT_"))); -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - encoding: "utf8", - ...options, - env: withoutGitVariables(options.env ?? process.env), - }); - if (result.status !== 0) { - throw new Error( - `${command} ${args.join(" ")} exited ${result.status}\n${result.stderr || result.stdout}` - ); - } - return result.stdout || ""; -} - -function writeJson(filePath, value) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); -} - -function writeLines(filePath, lines) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); -} - -/** Committed, because a repository with no HEAD answers some of the hook's git questions - * differently. The remote is what the project identifier is derived from. */ -function makeCheckout(root, name, project) { - const dir = path.join(root, name); - fs.mkdirSync(dir, { recursive: true }); - run("git", ["init", "-q", "-b", "main", dir]); - run("git", ["config", "user.email", "reference-week@example.invalid"], { cwd: dir }); - run("git", ["config", "user.name", "reference-week"], { cwd: dir }); - run("git", ["remote", "add", "origin", project.remote], { cwd: dir }); - writeJson(path.join(dir, ".aidd", "config.json"), { telemetry: { enabled: true } }); - fs.writeFileSync(path.join(dir, "README.md"), `# ${name}\n`); - run("git", ["add", "-A"], { cwd: dir }); - run("git", ["commit", "-q", "-m", "seed"], { cwd: dir }); - return dir; -} - -function fireHook({ event, payload, cwd, home, runsDir, at }) { - const result = spawnSync(process.execPath, ["--require", FROZEN_CLOCK, HOOK, event], { - cwd, - input: JSON.stringify(payload), - encoding: "utf8", - env: { - ...process.env, - HOME: home, - USERPROFILE: home, - AIDD_RUNS_DIR: runsDir, - AIDD_FROZEN_CLOCK: at, - }, - }); - // The hook exits 0 by contract, whatever it decides. Non-zero means it never ran. - if (result.status !== 0) { - throw new Error(`journal hook exited ${result.status}: ${result.stderr}`); - } -} - -/** One shape for every request in the week, so any number in the report traces back to a - * count of requests rather than to arithmetic nobody can redo by hand. */ -const COUNTERS = Object.freeze({ input: 100, output: 10, cacheRead: 20, cacheCreation: 30 }); - -function claudeTranscriptPath(home, sessionId) { - return path.join(home, ".claude", "projects", "reference-week", `${sessionId}.jsonl`); -} - -/** `attributionSkill` beside `usage` is what makes a Claude Code step `tool-stated`. An - * entry without it is the unattributed case; this week has both. */ -function writeClaudeTranscript(home, sessionId, entries) { - writeLines( - claudeTranscriptPath(home, sessionId), - entries.map((entry, index) => ({ - parentUuid: null, - isSidechain: false, - type: "assistant", - uuid: `${sessionId}-line-${index}`, - timestamp: entry.at, - sessionId, - ...(entry.skill ? { attributionSkill: entry.skill } : {}), - message: { - id: `msg_${sessionId}_${index}`, - type: "message", - role: "assistant", - model: entry.model, - content: "[REDACTED]", - usage: { - input_tokens: COUNTERS.input, - cache_creation_input_tokens: COUNTERS.cacheCreation, - cache_read_input_tokens: COUNTERS.cacheRead, - output_tokens: COUNTERS.output, - service_tier: "standard", - }, - }, - })) - ); -} - -/** The trailing uuid is the session identity on both sides — the hook's - * `codexSessionIdFromTranscriptPath` and the reader's `CODEX_ROLLOUT_LOCATION.matches`. */ -function codexRolloutPath(home, sessionId, day, stamp) { - const [year, month, dayOfMonth] = day.split("-"); - return path.join( - home, ".codex", "sessions", year, month, dayOfMonth, - `rollout-${stamp}-${sessionId}.jsonl` - ); -} - -/** A rollout names no skill anywhere, which is why Codex is here: its step can only come - * from a journal interval. */ -function writeCodexRollout(home, sessionId, day, stamp, entries) { - const lines = [ - { - timestamp: `${day}T00:00:00.000Z`, - type: "session_meta", - payload: { id: sessionId, session_id: sessionId }, - }, - ]; - for (const [index, entry] of entries.entries()) { - lines.push({ - timestamp: entry.at, - type: "turn_context", - payload: { turn_id: `${sessionId}-turn-${index}`, model: entry.model, effort: "medium" }, - }); - lines.push({ - timestamp: entry.at, - type: "event_msg", - payload: { - type: "token_count", - info: { - last_token_usage: { - input_tokens: COUNTERS.input + COUNTERS.cacheRead, - cached_input_tokens: COUNTERS.cacheRead, - output_tokens: COUNTERS.output, - reasoning_output_tokens: 0, - total_tokens: COUNTERS.input + COUNTERS.cacheRead + COUNTERS.output, - }, - }, - }, - }); - } - writeLines(codexRolloutPath(home, sessionId, day, stamp), lines); -} - -/** Copilot's only record is `kind: "session"` — `session.shutdown` totals the whole - * session. It must appear as its own line and never join the request totals. */ -function writeCopilotEvents(home, sessionId, startedAt, endedAt) { - writeLines(path.join(home, ".copilot", "session-state", sessionId, "events.jsonl"), [ - { - type: "session.start", - data: { sessionId, producer: "copilot-agent", copilotVersion: "1.0.80" }, - id: `${sessionId}-start`, - timestamp: startedAt, - parentId: null, - }, - { - type: "session.shutdown", - data: { - shutdownType: "routine", - tokenDetails: { - input: { tokenCount: 4000 }, - cache_read: { tokenCount: 0 }, - cache_write: { tokenCount: 900 }, - output: { tokenCount: 250 }, - }, - }, - id: `${sessionId}-shutdown`, - timestamp: endedAt, - parentId: null, - }, - ]); -} - -/** Ada, Monday, widgets: one orchestrated run. Step intervals are flat — a nested skill - * closes its parent — so three requests under three steps belong to one flow. */ -function seedAdaFlow({ projectDir, home, runsDir }) { - const sessionId = SESSION.adaFlow; - const base = { cwd: projectDir, home, runsDir }; - const payload = (extra) => ({ - session_id: sessionId, - cwd: projectDir, - transcript_path: claudeTranscriptPath(home, sessionId), - ...extra, - }); - const toolUsed = (at, extra) => - fireHook({ ...base, event: "tool-used", at, payload: payload({ hook_event_name: "PostToolUse", ...extra }) }); - - fireHook({ - ...base, - event: "session-start", - at: "2026-03-02T08:00:00Z", - payload: payload({ hook_event_name: "SessionStart" }), - }); - toolUsed("2026-03-02T08:05:00Z", { - tool_name: "Skill", - tool_input: { skill: FLOW_SKILL }, - prompt_id: "turn-1", - }); - toolUsed("2026-03-02T08:10:00Z", { - tool_name: "Write", - tool_input: { file_path: path.join(projectDir, "aidd_docs", "tasks", TASK_WITH_BACKLOG, "plan.md") }, - prompt_id: "turn-1", - }); - // Not redundant with the write: a write is recorded as `file_written`, while intervals - // open on `task_declared` alone. Both lines are here so the week shows each route — - // `declared` and `inferred` — on one task. - toolUsed("2026-03-02T08:12:00Z", { - tool_name: "Read", - tool_input: { file_path: `aidd_docs/tasks/${TASK_WITH_BACKLOG}/plan.md` }, - prompt_id: "turn-1", - }); - toolUsed("2026-03-02T08:15:00Z", { - tool_name: "Skill", - tool_input: { skill: "aidd-dev:01-plan" }, - prompt_id: "turn-1", - }); - toolUsed("2026-03-02T09:00:00Z", { - tool_name: "Skill", - tool_input: { skill: "aidd-dev:02-implement" }, - prompt_id: "turn-2", - }); - fireHook({ - ...base, - event: "turn-end", - at: "2026-03-02T10:00:00Z", - payload: payload({ hook_event_name: "Stop", prompt_id: "turn-2" }), - }); - - writeClaudeTranscript(home, sessionId, [ - { at: "2026-03-02T08:07:00.000Z", model: "claude-opus-5", skill: FLOW_SKILL }, - { at: "2026-03-02T08:30:00.000Z", model: "claude-opus-5", skill: "aidd-dev:01-plan" }, - { at: "2026-03-02T09:30:00.000Z", model: "claude-sonnet-5", skill: "aidd-dev:02-implement" }, - ]); -} - -/** Ada, Tuesday, same checkout, Codex: a second task, outside any flow. Both facts here are - * read out of a shell command's own text — the skill from a `SKILL.md` path, the task from - * a path under `aidd_docs/tasks/` — which is Codex's only route. */ -function seedAdaCodex({ projectDir, home, runsDir }) { - const sessionId = SESSION.adaCodex; - const stamp = "2026-03-03T09-00-00"; - const base = { cwd: projectDir, home, runsDir }; - const payload = (extra) => ({ - session_id: sessionId, - cwd: projectDir, - transcript_path: codexRolloutPath(home, sessionId, "2026-03-03", stamp), - ...extra, - }); - const toolUsed = (at, command) => - fireHook({ - ...base, - event: "tool-used", - at, - payload: payload({ - hook_event_name: "PostToolUse", - tool_name: "Bash", - tool_input: { command }, - turn_id: "codex-turn-1", - }), - }); - - fireHook({ - ...base, - event: "session-start", - at: "2026-03-03T09:00:00Z", - payload: payload({ hook_event_name: "SessionStart" }), - }); - toolUsed("2026-03-03T09:05:00Z", "cat plugins/aidd-dev/skills/02-implement/SKILL.md"); - toolUsed("2026-03-03T09:10:00Z", `cat aidd_docs/tasks/${TASK_WITHOUT_BACKLOG}/spec.md`); - fireHook({ - ...base, - event: "turn-end", - at: "2026-03-03T10:00:00Z", - payload: payload({ hook_event_name: "Stop" }), - }); - - writeCodexRollout(home, sessionId, "2026-03-03", stamp, [ - { at: "2026-03-03T09:30:00.000Z", model: "gpt-5.5" }, - ]); -} - -/** Bo, Wednesday, another project: work that declares nothing. The week's honest remainder - * — every declaring axis owes it a named row of its own. */ -function seedBoClaude({ projectDir, home, runsDir }) { - const sessionId = SESSION.boClaude; - const base = { cwd: projectDir, home, runsDir }; - const payload = (extra) => ({ - session_id: sessionId, - cwd: projectDir, - transcript_path: claudeTranscriptPath(home, sessionId), - ...extra, - }); - - fireHook({ ...base, event: "session-start", at: "2026-03-04T08:00:00Z", payload: payload({ hook_event_name: "SessionStart" }) }); - fireHook({ ...base, event: "turn-end", at: "2026-03-04T09:00:00Z", payload: payload({ hook_event_name: "Stop" }) }); - - // No `attributionSkill`: the tool states no step, so neither does the report. - writeClaudeTranscript(home, sessionId, [ - { at: "2026-03-04T08:20:00.000Z", model: "claude-haiku-4-5" }, - { at: "2026-03-04T08:40:00.000Z", model: "claude-haiku-4-5" }, - ]); -} - -/** Bo, Wednesday, Codex: a second tool on one day, so `by_day` and `by_tool` cannot be read - * off the same partition of the week. */ -function seedBoCodex({ projectDir, home, runsDir }) { - const sessionId = SESSION.boCodex; - const stamp = "2026-03-04T10-00-00"; - const base = { cwd: projectDir, home, runsDir }; - const payload = (extra) => ({ - session_id: sessionId, - cwd: projectDir, - transcript_path: codexRolloutPath(home, sessionId, "2026-03-04", stamp), - ...extra, - }); - - fireHook({ ...base, event: "session-start", at: "2026-03-04T10:00:00Z", payload: payload({ hook_event_name: "SessionStart" }) }); - fireHook({ ...base, event: "turn-end", at: "2026-03-04T11:00:00Z", payload: payload({ hook_event_name: "Stop" }) }); - - writeCodexRollout(home, sessionId, "2026-03-04", stamp, [ - { at: "2026-03-04T10:30:00.000Z", model: "gpt-5.5" }, - ]); -} - -/** Bo, Wednesday, Copilot. `sessionId` with no `hook_event_name` is how the hook tells - * Copilot's canonical builder from every other host — the spelling is the discrimination. */ -function seedBoCopilot({ projectDir, home, runsDir }) { - const sessionId = SESSION.boCopilot; - const base = { cwd: projectDir, home, runsDir }; - const payload = { sessionId, cwd: projectDir }; - - fireHook({ ...base, event: "session-start", at: "2026-03-04T13:00:00Z", payload }); - fireHook({ ...base, event: "turn-end", at: "2026-03-04T14:00:00Z", payload }); - - writeCopilotEvents(home, sessionId, "2026-03-04T13:00:00.000Z", "2026-03-04T14:00:00.000Z"); -} - -// A minimal PATH, never the caller's own: the OpenCode reader shells out to an `opencode` -// binary and waits up to 10s per session, so a machine that happens to have a tool installed -// would pay a cost a machine without it does not. `git` lives here on every platform. -const MINIMAL_PATH = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(path.delimiter); - -function sandboxEnv(home, { sinkDir, runsDir }) { - return { - ...process.env, - PATH: MINIMAL_PATH, - Path: MINIMAL_PATH, - HOME: home, - USERPROFILE: home, - XDG_CONFIG_HOME: path.join(home, ".config"), - APPDATA: path.join(home, "AppData", "Roaming"), - AIDD_TELEMETRY_DIR: path.join(sinkDir, "telemetry"), - AIDD_RUNS_DIR: runsDir, - AIDD_SKIP_MARKETPLACE_REFRESH: "1", - }; -} - -function cli(args, who, { sinkDir, runsDir, cliPath }) { - return run(process.execPath, [cliPath, ...args], { - cwd: who.projectDir, - env: sandboxEnv(who.home, { sinkDir, runsDir }), - }); -} - -/** Builds the scenario under `root` and reads it into the shared sink, leaving it ready for - * `telemetry report`. Writes nothing outside `root` and reads nothing from the machine. */ -function buildReferenceWeek({ root, cliPath = CLI }) { - if (!fs.existsSync(cliPath)) { - throw new Error(`the CLI is not built at ${cliPath} — run \`pnpm build\` in cli/ first`); - } - - const sinkDir = path.join(root, "destination", "aidd"); - const runsDir = path.join(root, "destination", "runs"); - const adaHome = path.join(root, "ada-home"); - const boHome = path.join(root, "bo-home"); - for (const dir of [sinkDir, runsDir, adaHome, boHome]) fs.mkdirSync(dir, { recursive: true }); - - const widgets = makeCheckout(root, "ada-widgets", PROJECTS.widgets); - const gadgets = makeCheckout(root, "bo-gadgets", PROJECTS.gadgets); - - // Both task folders live in the checkout the report runs from: `TaskBacklogAdapter` is - // rooted at one project, so a declaration in another checkout is unreadable from here. - const taskDir = path.join(widgets, "aidd_docs", "tasks", TASK_WITH_BACKLOG); - fs.mkdirSync(taskDir, { recursive: true }); - fs.writeFileSync(path.join(taskDir, "plan.md"), "# Gouvernail\n"); - writeJson(path.join(taskDir, "backlog-link.json"), { - backlog: BACKLOG_ITEM, - written_at: "2026-03-02T08:10:00Z", - written_by: "aidd-dev:01-plan", - }); - // Declared, and declaring no backlog item — a different state from a task nobody declared. - const plainTaskDir = path.join(widgets, "aidd_docs", "tasks", TASK_WITHOUT_BACKLOG); - fs.mkdirSync(plainTaskDir, { recursive: true }); - fs.writeFileSync(path.join(plainTaskDir, "spec.md"), "# Pricing\n"); - - seedAdaFlow({ projectDir: widgets, home: adaHome, runsDir }); - seedAdaCodex({ projectDir: widgets, home: adaHome, runsDir }); - seedBoClaude({ projectDir: gadgets, home: boHome, runsDir }); - seedBoCodex({ projectDir: gadgets, home: boHome, runsDir }); - seedBoCopilot({ projectDir: gadgets, home: boHome, runsDir }); - - const ada = { home: adaHome, projectDir: widgets, person: PEOPLE.ada }; - const bo = { home: boHome, projectDir: gadgets, person: PEOPLE.bo }; - for (const who of [ada, bo]) { - const where = { sinkDir, runsDir, cliPath }; - // One call: the identifier and the name it goes by are one decision. - cli( - ["telemetry", "identity", "use", who.person.personId, "--name", who.person.displayName], - who, - where - ); - // Reading is per machine: each person's tool files are under their own home. - cli(["telemetry", "read"], who, where); - } - - return { - /** The one directory a caller may delete from, to ask what `report` rebuilds alone. */ - sinkDir, - reportFrom: widgets, - env: sandboxEnv(adaHome, { sinkDir, runsDir }), - period: PERIOD, - expected: EXPECTED, - cliPath, - }; -} - -/** `telemetry report` over the built week. Runs from the checkout holding the task folders, - * under Ada's home — the person axis comes off the stored records, never off who asks. */ -function reportReferenceWeek(week, extraArgs = []) { - return run( - process.execPath, - [week.cliPath, "telemetry", "report", "--from", week.period.from, "--to", week.period.to, ...extraArgs], - { cwd: week.reportFrom, env: week.env } - ); -} - -// Three, deliberately: everything else reaches a caller through the object -// `buildReferenceWeek` returns or through `EXPECTED`, and a second route could drift. -module.exports = { buildReferenceWeek, reportReferenceWeek, EXPECTED }; diff --git a/scripts/probe-identifier-join.cjs b/scripts/probe-identifier-join.cjs deleted file mode 100755 index 6b80e6079..000000000 --- a/scripts/probe-identifier-join.cjs +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env node -/** - * Re-checks, without spending quota, the assumption the whole telemetry layer rests on: - * the session identifier a hook receives is the one the tool's own files carry, so a cost - * read out of those files can be placed against the work the journal recorded. - * - * It costs nothing because Claude Code mints its session identifier, fires `SessionStart` - * and opens its transcript *before* it ever reaches the API: pointed at a dead address with - * a fake key and an empty home, it still does all three and then fails the call. That is - * what lets this run on every pull request rather than nightly. - * - * Exit codes are the point, not decoration: - * 0 the join holds - * 1 the tool changed — everything ran, and the identifiers disagree - * 2 the probe is broken — it could not get far enough to have an opinion - * Never report the second as the first: a missing binary is not evidence about the tool. - */ -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); - -const TOOL_CHANGED = 1; -const PROBE_BROKEN = 2; - -/** Generous on purpose: against a dead address the tool retries, and every artefact this - * probe reads is written long before the retries end, so the timeout bounds the run. */ -const RUN_TIMEOUT_MS = 90_000; - -/** Printed whichever way the run ends: a silent removal has to be visible in the log, - * not only in a red cross. */ -const CHECKED = []; - -function report(name, verdict, detail) { - CHECKED.push({ name, verdict, detail }); -} - -function printReport() { - process.stdout.write("\nattributes checked by this probe\n"); - for (const { name, verdict, detail } of CHECKED) { - process.stdout.write(` ${verdict.padEnd(8)} ${name.padEnd(28)} ${detail}\n`); - } -} - -function fail(code, message) { - printReport(); - const kind = code === PROBE_BROKEN ? "PROBE BROKEN" : "TOOL CHANGED"; - process.stderr.write(`\n${kind}: ${message}\n`); - process.exit(code); -} - -function resolveCli() { - const built = path.resolve(__dirname, "..", "cli", "dist", "cli.js"); - if (!fs.existsSync(built)) fail(PROBE_BROKEN, `the CLI is not built at ${built}`); - return built; -} - -function resolveJournalHook() { - const hook = path.resolve(__dirname, "..", "plugins", "aidd-telemetry", "hooks", "journal.cjs"); - if (!fs.existsSync(hook)) fail(PROBE_BROKEN, `the journal hook is not at ${hook}`); - return hook; -} - -function requireClaude() { - const found = spawnSync(process.platform === "win32" ? "where" : "which", ["claude"], { - encoding: "utf8", - }); - if (found.status !== 0 || !found.stdout.trim()) { - fail(PROBE_BROKEN, "`claude` is not on PATH; install it before running this probe"); - } - const version = spawnSync("claude", ["--version"], { encoding: "utf8" }); - return (version.stdout || "").trim(); -} - -/** Two hooks on one event: the capture hook witnesses what the tool handed over, the - * journal hook is the real one — which is what makes a disagreement attributable. */ -function makeProject(root, capture, journalHook) { - const project = path.join(root, "project"); - fs.mkdirSync(path.join(project, ".claude"), { recursive: true }); - fs.mkdirSync(path.join(project, ".aidd"), { recursive: true }); - fs.writeFileSync( - path.join(project, ".aidd", "config.json"), - `${JSON.stringify({ telemetry: { enabled: true } })}\n` - ); - const witness = path.join(root, "capture.cjs"); - fs.writeFileSync(witness, CAPTURE_HOOK); - fs.writeFileSync( - path.join(project, ".claude", "settings.local.json"), - `${JSON.stringify( - { - hooks: { - SessionStart: [ - { - hooks: [ - { type: "command", command: `node ${witness} SessionStart ${capture}` }, - { type: "command", command: `node ${journalHook} session-start` }, - ], - }, - ], - }, - }, - null, - 2 - )}\n` - ); - spawnSync("git", ["init", "-q", project]); - spawnSync("git", ["remote", "add", "origin", "git@github.com:aidd/identifier-join-probe.git"], { - cwd: project, - }); - return project; -} - -const CAPTURE_HOOK = `const fs=require("node:fs"),path=require("node:path"); -let raw="";process.stdin.setEncoding("utf8"); -process.stdin.on("data",(c)=>{raw+=c}); -process.stdin.on("end",()=>{try{fs.mkdirSync(process.argv[3],{recursive:true}); -fs.writeFileSync(path.join(process.argv[3],process.argv[2]+".json"),raw)}catch{}process.exit(0)}); -`; - -/** The name is half the join: the hook writes `__.jsonl`. */ -function journalledSessionIds(project) { - const dir = path.join(project, "aidd_docs", "runs"); - if (!fs.existsSync(dir)) return []; - return fs - .readdirSync(dir) - .filter((name) => name.endsWith(".jsonl") && name.includes("__")) - .map((name) => name.slice(name.indexOf("__") + 2, -".jsonl".length)); -} - -/** `claude-code-transcript.ts` locates a session by the identifier in the file name, so - * this reads the same fact the production reader will. */ -function transcriptSessionIds(configDir) { - const root = path.join(configDir, "projects"); - if (!fs.existsSync(root)) return []; - const ids = []; - for (const dir of fs.readdirSync(root)) { - const full = path.join(root, dir); - if (!fs.statSync(full).isDirectory()) continue; - for (const name of fs.readdirSync(full)) { - if (name.endsWith(".jsonl")) ids.push(name.slice(0, -".jsonl".length)); - } - } - return ids; -} - -function main() { - const version = requireClaude(); - const cli = resolveCli(); - const journalHook = resolveJournalHook(); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-join-probe-")); - const capture = path.join(root, "capture"); - const sinkDir = path.join(root, "sink"); - const home = path.join(root, "home"); - const configDir = path.join(home, ".claude"); - const project = makeProject(root, capture, journalHook); - - // A dead address, a fake key and an empty home: the session opens, both hooks fire and - // the transcript is created before any of the three matter. - const run = spawnSync("claude", ["-p", "say OK"], { - cwd: project, - encoding: "utf8", - timeout: RUN_TIMEOUT_MS, - killSignal: "SIGKILL", - env: { - ...process.env, - HOME: home, - CLAUDE_CONFIG_DIR: configDir, - ANTHROPIC_BASE_URL: "http://127.0.0.1:9", - ANTHROPIC_API_KEY: "sk-ant-probe-not-a-real-key", - }, - }); - - const capturedPath = path.join(capture, "SessionStart.json"); - if (!fs.existsSync(capturedPath)) { - report("SessionStart hook", "MISSING", "no payload captured"); - fail( - TOOL_CHANGED, - `the SessionStart hook never fired on ${version}. It fired before the API call on ` + - `2.1.250, which is what made this probe free. claude exited ${run.status}.` - ); - } - const payload = JSON.parse(fs.readFileSync(capturedPath, "utf8")); - const hookId = payload.session_id; - if (typeof hookId !== "string" || hookId === "") { - report("session_id (hook)", "MISSING", `keys: ${Object.keys(payload).sort().join(", ")}`); - fail(TOOL_CHANGED, "the SessionStart payload carries no session_id"); - } - report("session_id (hook)", "present", hookId); - - const journalled = journalledSessionIds(project); - report("session id (journal)", journalled.length ? "present" : "MISSING", journalled.join(", ")); - if (!journalled.includes(hookId)) { - fail( - TOOL_CHANGED, - `the journal did not record the identifier the hook was handed on ${version}: the hook ` + - `saw ${hookId}, the journal names ${journalled.join(", ") || "nothing"}.` - ); - } - - const transcripts = transcriptSessionIds(configDir); - report("session id (transcript)", transcripts.length ? "present" : "MISSING", transcripts.join(", ")); - if (transcripts.length === 0) { - fail( - TOOL_CHANGED, - `${version} wrote no transcript against a dead API. It did on 2.1.250; if this is now ` + - "deliberate, the probe has to run real sessions and stops being free." - ); - } - if (!transcripts.includes(hookId)) { - fail( - TOOL_CHANGED, - `the join is broken on ${version}: the hook saw ${hookId}, the transcript is named ` + - `${transcripts.join(", ")}. Every attribution in this layer assumes they are one value.` - ); - } - - // What makes it *this system's* join rather than the probe's own comparison: the shipped - // reader resolves the journalled session against the transcript, unaided. - const read = spawnSync(process.execPath, [cli, "telemetry", "read"], { - cwd: project, - encoding: "utf8", - env: { ...process.env, HOME: home, AIDD_USER_CONFIG_DIR: sinkDir }, - }); - const stdout = read.stdout || ""; - // "no session found" says the join failed; "read" says it held and the session carried no - // billable record, which is right against a dead API. Two sentences on purpose. - const found = /Claude Code:\s*read/u.test(stdout); - report("read locates the session", found ? "present" : "MISSING", stdout.trim().split("\n")[1] ?? ""); - if (!found) { - fail( - TOOL_CHANGED, - `\`aidd telemetry read\` did not locate the session the journal named on ${version}. ` + - `It exited ${read.status} and said:\n${stdout}` - ); - } - - printReport(); - process.stdout.write(`\nthe identifier join holds on ${version} — ${hookId}\n`); - process.stdout.write("cost: zero tokens, no credentials, no model call reached.\n"); -} - -main(); diff --git a/scripts/telemetry-reference-week.cjs b/scripts/telemetry-reference-week.cjs deleted file mode 100644 index 8f610779f..000000000 --- a/scripts/telemetry-reference-week.cjs +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env node -/** - * Prints the reference week: one period, measured end to end, broken down every way the - * report knows how. The same builder the e2e test asserts against, so the demo cannot drift - * from the test. No tool binary, no network, everything under a throwaway temp dir. - */ - -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); - -const { - buildReferenceWeek, - reportReferenceWeek, - EXPECTED, -} = require("./lib/telemetry-reference-week.cjs"); - -// In the order a person reads them: the total, then when, then what the work was, then -// who and where. -const AXES = [ - "total", - "day", - "flow", - "step", - "task", - "backlog", - "model", - "tool", - "project", - "person", -]; - -function out(line = "") { - process.stdout.write(`${line}\n`); -} - -function main() { - const keep = process.argv.includes("--keep"); - const asJson = process.argv.includes("--json"); - const root = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-reference-week-")); - - try { - const week = buildReferenceWeek({ root }); - - if (asJson) { - process.stdout.write(reportReferenceWeek(week, ["--json"])); - return; - } - - out("the reference week"); - out("=================="); - out(); - out("Two people on two machines, two projects, three tools, three days."); - out(); - out("The journal is written by the shipped hook, spawned as a process with a payload on"); - out("stdin. Everything else here is authored: each tool's session file (in that tool's"); - out("own on-disk format, shapes taken from the captured fixtures), the task folders and"); - out("their backlog declaration, the git remotes, the models and the counters. The clock"); - out("the hook stamps with is supplied too. What is being proved is the reading — every"); - out("figure below is produced by the shipped CLI opening those files for itself."); - out(); - out("No tool in the registry states an amount, and the framework holds no price table"); - out("by decision, so the week has counters and no currency. That is settled, not pending:"); - out("see aidd_docs/memory/internal/decisions/a-price-table-is-the-destination-s.md"); - out(); - out(`period ${week.period.from} to ${week.period.to}`); - out(`expected ${EXPECTED.requests} requests, every axis reconciling to it`); - out(`destination ${week.sinkDir}`); - out(); - - for (const axis of AXES) { - out(reportReferenceWeek(week, ["--axis", axis]).trimEnd()); - out(); - } - - if (keep) out(`kept: ${root}`); - } finally { - if (!keep) fs.rmSync(root, { recursive: true, force: true }); - } -} - -main(); From da23cff3b85fd015aa07d667a055994c597609d4 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 9 Oct 2026 18:56:32 +0200 Subject: [PATCH 04/73] feat(telemetry): count each billed Claude Code call once Read Claude Code transcripts into one usage record per billed model call. - key message.id:requestId, or message.id:sessionId:timestamp without a request id - drop the synthetic model; isSidechain or agentId makes the call a subagent - cache_write and cache_write_1h are summed over the message iterations; the one-hour part is unknown when any iteration lacks its split - each advisor iteration is its own record, keyed #advisor - an unrecognised shape is reported and never guessed; an unknown counter is null, never 0 - global fold across every file: largest total, then earliest time, then lowest session id, independent of input order - source: recursive walk of the projects root including subagents and *.jsonl.superseded-* set-asides, byte-offset reads, an unterminated last line is left unconsumed, a shrunk or replaced file is re-read whole Re-adds the telemetry context to the architecture guards (public modules, biome layer overrides, codebase map, mutation scope, tool-name baseline). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- cli/aidd_docs/memory/codebase-map.md | 6 + cli/biome.json | 120 ++++++- cli/mutation-scopes.json | 4 + cli/package.json | 1 + .../application/read-claude-usage-use-case.ts | 34 ++ .../telemetry/domain/claude-projects-root.ts | 8 + .../domain/formats/claude-transcript-usage.ts | 138 ++++++++ .../domain/ports/transcript-source.ts | 15 + .../telemetry/domain/transcript-position.ts | 27 ++ .../contexts/telemetry/domain/usage-fold.ts | 40 +++ .../contexts/telemetry/domain/usage-record.ts | 41 +++ .../claude-transcript-source-adapter.ts | 73 +++++ .../context-boundary.arch.test.ts | 14 + .../tool-addition-cost.arch.test.ts | 5 + ...-claude-usage-use-case.integration.test.ts | 148 +++++++++ .../domain/claude-projects-root.unit.test.ts | 17 + .../claude-transcript-usage.unit.test.ts | 309 ++++++++++++++++++ .../domain/transcript-position.unit.test.ts | 41 +++ .../telemetry/domain/usage-fold.unit.test.ts | 111 +++++++ ...nscript-source-adapter.integration.test.ts | 155 +++++++++ .../projects/proj-a/s-0.jsonl.superseded-1 | 1 + .../claude-usage/projects/proj-a/s-1.jsonl | 4 + .../proj-a/s-1/subagents/agent-x.jsonl | 1 + .../claude-usage/projects/proj-a/s-2.jsonl | 1 + 24 files changed, 1310 insertions(+), 4 deletions(-) create mode 100644 cli/src/contexts/telemetry/application/read-claude-usage-use-case.ts create mode 100644 cli/src/contexts/telemetry/domain/claude-projects-root.ts create mode 100644 cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/transcript-source.ts create mode 100644 cli/src/contexts/telemetry/domain/transcript-position.ts create mode 100644 cli/src/contexts/telemetry/domain/usage-fold.ts create mode 100644 cli/src/contexts/telemetry/domain/usage-record.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/claude-transcript-source-adapter.ts create mode 100644 cli/tests/contexts/telemetry/application/read-claude-usage-use-case.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/claude-projects-root.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/transcript-position.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/usage-fold.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/claude-transcript-source-adapter.integration.test.ts create mode 100644 cli/tests/fixtures/claude-usage/projects/proj-a/s-0.jsonl.superseded-1 create mode 100644 cli/tests/fixtures/claude-usage/projects/proj-a/s-1.jsonl create mode 100644 cli/tests/fixtures/claude-usage/projects/proj-a/s-1/subagents/agent-x.jsonl create mode 100644 cli/tests/fixtures/claude-usage/projects/proj-a/s-2.jsonl diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index e32385e4d..6e2213ea0 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -48,6 +48,12 @@ src/ │ │ │ ├── opencode/ │ │ │ └── vscode/ │ │ └── infrastructure/ +│ ├── telemetry/ # what each billed model call consumed, read from a tool's own transcripts +│ │ ├── application/ +│ │ ├── domain/ +│ │ │ ├── formats/ +│ │ │ └── ports/ +│ │ └── infrastructure/ │ └── translate/ # canonical source to target-native content │ ├── application/ │ │ └── strategies/ diff --git a/cli/biome.json b/cli/biome.json index d5f673670..69994b73f 100644 --- a/cli/biome.json +++ b/cli/biome.json @@ -123,6 +123,10 @@ "**/runtime/**" ], "message": "domain must not import application, infrastructure, presentation or runtime" + }, + { + "group": ["**/telemetry/**"], + "message": "framework may import translate, tools and distribution, never telemetry \u2014 see context-graph.arch.test.ts" } ] } @@ -143,6 +147,10 @@ { "group": ["**/infrastructure/**"], "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" + }, + { + "group": ["**/telemetry/**"], + "message": "framework may import translate, tools and distribution, never telemetry \u2014 see context-graph.arch.test.ts" } ] } @@ -161,8 +169,8 @@ "options": { "patterns": [ { - "group": ["**/presentation/**", "**/runtime/**"], - "message": "framework/infrastructure may import translate, tools and distribution, never presentation or runtime \u2014 see context-graph.arch.test.ts" + "group": ["**/telemetry/**", "**/presentation/**", "**/runtime/**"], + "message": "framework/infrastructure may import translate, tools and distribution, never telemetry, presentation or runtime \u2014 see context-graph.arch.test.ts" } ] } @@ -190,7 +198,7 @@ "message": "domain must not import application, infrastructure, presentation or runtime" }, { - "group": ["**/framework/**", "**/distribution/**"], + "group": ["**/framework/**", "**/distribution/**", "**/telemetry/**"], "message": "translate may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" } ] @@ -217,6 +225,7 @@ "group": [ "**/framework/**", "**/distribution/**", + "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -242,6 +251,7 @@ "group": [ "**/framework/**", "**/distribution/**", + "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -273,7 +283,12 @@ "message": "domain must not import application, infrastructure, presentation or runtime" }, { - "group": ["**/framework/**", "**/translate/**", "**/distribution/**"], + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/telemetry/**" + ], "message": "tools imports no other context \u2014 every context that needs it imports tools, not the reverse (context-graph.arch.test.ts)" } ] @@ -301,6 +316,7 @@ "**/framework/**", "**/translate/**", "**/distribution/**", + "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -327,6 +343,7 @@ "**/framework/**", "**/translate/**", "**/distribution/**", + "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -362,6 +379,7 @@ "**/framework/**", "**/translate/**", "**/tools/**", + "**/telemetry/**", "**/manifest.js" ], "message": "distribution knows no tool, no translation and no installation record \u2014 it says where content comes from, not what is done with it (context-graph.arch.test.ts)" @@ -390,6 +408,7 @@ "group": [ "**/translate/**", "**/tools/**", + "**/telemetry/**", "**/presentation/**", "**/runtime/**" ], @@ -420,6 +439,7 @@ "**/framework/**", "**/translate/**", "**/tools/**", + "**/telemetry/**", "**/presentation/**" ], "message": "distribution knows no tool, no translation and no installation record, and reaches no presentation (context-graph.arch.test.ts). Its runtime imports are the grandfathered exception recorded there as BASELINE, not extended here" @@ -431,6 +451,98 @@ } } }, + { + "includes": ["src/contexts/telemetry/domain/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/application/**", + "**/infrastructure/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "domain must not import application, infrastructure, presentation or runtime" + }, + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/tools/**" + ], + "message": "telemetry imports only the kernel \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/telemetry/application/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/infrastructure/**"], + "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" + }, + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/tools/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "telemetry imports only the kernel \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/telemetry/infrastructure/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/tools/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "telemetry imports only the kernel \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, { "includes": ["src/contexts/*/domain/**/*.ts", "src/contexts/*/application/**/*.ts"], "linter": { diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index 7fed78f8f..88b00550a 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -17,6 +17,10 @@ ], "break": 94 }, + "telemetry": { + "mutate": "src/contexts/telemetry/**/*.ts", + "break": 94 + }, "translate": { "mutate": "src/contexts/translate/**/*.ts", "break": 85 diff --git a/cli/package.json b/cli/package.json index 99746b3df..d88fbca50 100644 --- a/cli/package.json +++ b/cli/package.json @@ -69,6 +69,7 @@ "test:mutation:tools-cursor": "node scripts/run-mutation.mjs tools-cursor", "test:mutation:tools-kilo": "node scripts/run-mutation.mjs tools-kilo", "test:mutation:tools-opencode": "node scripts/run-mutation.mjs tools-opencode", + "test:mutation:telemetry": "node scripts/run-mutation.mjs telemetry", "test:mutation:translate": "node scripts/run-mutation.mjs translate", "test:mutation:distribution": "node scripts/run-mutation.mjs distribution", "test:mutation:framework": "node scripts/run-mutation.mjs framework", diff --git a/cli/src/contexts/telemetry/application/read-claude-usage-use-case.ts b/cli/src/contexts/telemetry/application/read-claude-usage-use-case.ts new file mode 100644 index 000000000..fca4df1ee --- /dev/null +++ b/cli/src/contexts/telemetry/application/read-claude-usage-use-case.ts @@ -0,0 +1,34 @@ +import { readClaudeUsageLine } from "../domain/formats/claude-transcript-usage.js"; +import type { TranscriptSource } from "../domain/ports/transcript-source.js"; +import type { TranscriptPosition } from "../domain/transcript-position.js"; +import { foldUsage } from "../domain/usage-fold.js"; +import type { UsageRecord } from "../domain/usage-record.js"; + +export interface ClaudeUsageReading { + /** One record per billed call, folded across every file read. */ + readonly records: readonly UsageRecord[]; + /** Where each file read now stands, to hand back next time. */ + readonly positions: ReadonlyMap; + /** Shapes seen and not recognised, each prefixed with its file. */ + readonly unrecognised: readonly string[]; +} + +export class ReadClaudeUsageUseCase { + constructor(private readonly source: TranscriptSource) {} + + async execute(since: ReadonlyMap): Promise { + const candidates: UsageRecord[] = []; + const unrecognised: string[] = []; + const positions = new Map(since); + for (const path of await this.source.list()) { + const read = await this.source.read(path, since.get(path) ?? null); + positions.set(path, read.position); + for (const line of read.lines) { + const outcome = readClaudeUsageLine(line); + candidates.push(...outcome.records); + unrecognised.push(...outcome.unrecognised.map((reason) => `${path}: ${reason}`)); + } + } + return { records: foldUsage(candidates), positions, unrecognised }; + } +} diff --git a/cli/src/contexts/telemetry/domain/claude-projects-root.ts b/cli/src/contexts/telemetry/domain/claude-projects-root.ts new file mode 100644 index 000000000..834b1fcc7 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/claude-projects-root.ts @@ -0,0 +1,8 @@ +import { join } from "node:path"; + +/** The directory holding every transcript. `configDir` is `CLAUDE_CONFIG_DIR`; reading the + * environment is the composition root's job, not the domain's. */ +export function claudeProjectsRoot(configDir: string | undefined, home: string): string { + const base = configDir === undefined || configDir === "" ? join(home, ".claude") : configDir; + return join(base, "projects"); +} diff --git a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts new file mode 100644 index 000000000..948187323 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts @@ -0,0 +1,138 @@ +import { asPlainObject } from "../../../../kernel/reading/plain-object.js"; +import type { UsageRecord } from "../usage-record.js"; + +export interface ClaudeUsageLine { + readonly records: readonly UsageRecord[]; + /** One entry per shape not recognised, never turned into a record. */ + readonly unrecognised: readonly string[]; +} + +type Json = Record; + +const SYNTHETIC_MODEL = ""; +const NONE: ClaudeUsageLine = { records: [], unrecognised: [] }; + +function counter(value: unknown): number | null { + return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value !== "" ? value : null; +} + +function unrecognised(reason: string): ClaudeUsageLine { + return { records: [], unrecognised: [reason] }; +} + +/** A counter that must be there: absent or malformed is unknown, and said so. */ +function expected(source: Json, field: string, where: string, notes: string[]): number | null { + const value = counter(source[field]); + if (value === null) notes.push(`${where}.${field} is missing or not a count`); + return value; +} + +function messageIterations(usage: Json, notes: string[]): Json[] { + const raw = usage.iterations; + if (raw === undefined) return []; + if (!Array.isArray(raw)) { + notes.push("usage.iterations is not a list"); + return []; + } + return raw.flatMap((entry: unknown) => { + const iteration = asPlainObject(entry); + if (iteration === null) notes.push("usage.iterations holds a non-object"); + return iteration === null ? [] : [iteration]; + }); +} + +/** The top-level `cache_creation` split reflects only the first iteration, so the writes are + * summed over every message iteration. */ +function cacheWrites( + usage: Json, + iterations: readonly Json[], + notes: string[] +): { readonly total: number | null; readonly oneHour: number | null } { + const messages = iterations.filter((iteration) => iteration.type === "message"); + const parts = messages.length > 0 ? messages : [usage]; + const writes = parts.map((part) => expected(part, "cache_creation_input_tokens", "usage", notes)); + const hours = parts.map((part) => + counter(asPlainObject(part.cache_creation)?.ephemeral_1h_input_tokens) + ); + return { + total: writes.includes(null) ? null : sum(writes), + oneHour: hours.includes(null) ? null : sum(hours), + }; +} + +function sum(values: readonly (number | null)[]): number { + return values.reduce((all, value) => all + (value ?? 0), 0); +} + +export function readClaudeUsageLine(line: string): ClaudeUsageLine { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return unrecognised("a complete line is not JSON"); + } + const root = asPlainObject(parsed); + if (root === null) return unrecognised("a line is not an object"); + if (root.type !== "assistant") return NONE; + const message = asPlainObject(root.message); + if (message === null || message.usage === undefined) return NONE; + if (message.model === SYNTHETIC_MODEL) return NONE; + const usage = asPlainObject(message.usage); + if (usage === null) return unrecognised("message.usage is not an object"); + + const id = text(message.id); + const sessionId = text(root.sessionId); + const at = text(root.timestamp); + if (id === null || sessionId === null || at === null) { + return unrecognised("a usage line lacks message.id, sessionId or timestamp"); + } + + const notes: string[] = []; + const requestId = text(root.requestId); + const key = requestId === null ? `${id}:${sessionId}:${at}` : `${id}:${requestId}`; + const iterations = messageIterations(usage, notes); + const writes = cacheWrites(usage, iterations, notes); + const shared = { + tool: "claude-code", + tool_version: text(root.version), + session_id: sessionId, + at, + reasoning: null, + cwd: text(root.cwd), + git_branch: text(root.gitBranch), + } as const; + + const main: UsageRecord = { + ...shared, + key, + agent: root.isSidechain === true || text(root.agentId) !== null ? "subagent" : "main", + model: text(message.model), + input: expected(usage, "input_tokens", "usage", notes), + output: expected(usage, "output_tokens", "usage", notes), + cache_read: expected(usage, "cache_read_input_tokens", "usage", notes), + cache_write: writes.total, + cache_write_1h: writes.oneHour, + }; + + const advisors = iterations + .filter((iteration) => iteration.type === "advisor_message") + .map( + (advisor, n): UsageRecord => ({ + ...shared, + key: `${key}#advisor${n}`, + agent: "advisor", + model: text(advisor.model), + input: expected(advisor, "input_tokens", "advisor", notes), + 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, + }) + ); + + return { records: [main, ...advisors], unrecognised: notes }; +} diff --git a/cli/src/contexts/telemetry/domain/ports/transcript-source.ts b/cli/src/contexts/telemetry/domain/ports/transcript-source.ts new file mode 100644 index 000000000..d25fe7a71 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/transcript-source.ts @@ -0,0 +1,15 @@ +import type { TranscriptPosition } from "../transcript-position.js"; + +export interface TranscriptRead { + /** The complete lines after the position; an unterminated last line is left for next time. */ + readonly lines: readonly string[]; + readonly position: TranscriptPosition; + /** The file was not the one the position described, so `lines` start at its beginning. */ + readonly restarted: boolean; +} + +export interface TranscriptSource { + /** Every transcript the tool keeps, set-asides and sub-agents included. */ + list(): Promise; + read(path: string, since: TranscriptPosition | null): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/transcript-position.ts b/cli/src/contexts/telemetry/domain/transcript-position.ts new file mode 100644 index 000000000..75e2a2c46 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/transcript-position.ts @@ -0,0 +1,27 @@ +/** Where a previous read of one transcript stopped, and the file it stopped in. */ +export interface TranscriptPosition { + /** Bytes consumed: the end of the last complete line. */ + readonly offset: number; + /** The file's size when read. */ + readonly size: number; + /** Device and inode, or the nearest equivalent: names the file, not its path. */ + readonly identity: string; +} + +export interface TranscriptStat { + readonly size: number; + readonly identity: string; +} + +/** Where to read from. A file that shrank or changed identity is no longer the one the + * position describes, so it is read whole. */ +export function resumeOffset( + since: TranscriptPosition | null, + now: TranscriptStat +): { readonly offset: number; readonly restarted: boolean } { + if (since === null) return { offset: 0, restarted: false }; + if (now.identity !== since.identity || now.size < since.size) { + return { offset: 0, restarted: true }; + } + return { offset: since.offset, restarted: false }; +} diff --git a/cli/src/contexts/telemetry/domain/usage-fold.ts b/cli/src/contexts/telemetry/domain/usage-fold.ts new file mode 100644 index 000000000..1e903ebd6 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/usage-fold.ts @@ -0,0 +1,40 @@ +import { type UsageRecord, usageTotal } from "./usage-record.js"; + +function instant(at: string): number { + return Date.parse(at); +} + +/** Earlier is smaller; a time that does not parse falls back to its text. */ +function compareAt(a: string, b: string): number { + const x = instant(a); + const y = instant(b); + if (Number.isNaN(x) || Number.isNaN(y)) return a < b ? -1 : a > b ? 1 : 0; + return x - y; +} + +function compareText(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** Negative when `a` is the one to keep: the largest total, then the earliest time, then the + * lowest session id, then the serialised record, so no input order can change the winner. */ +function compareWinner(a: UsageRecord, b: UsageRecord): number { + return ( + usageTotal(b) - usageTotal(a) || + compareAt(a.at, b.at) || + compareText(a.session_id, b.session_id) || + compareText(JSON.stringify(a), JSON.stringify(b)) + ); +} + +/** One record per `(tool, key)` across everything given: a streamed call and a resumed copy + * show the same call several times, and only one of them is the call. */ +export function foldUsage(records: Iterable): UsageRecord[] { + const winners = new Map(); + for (const record of records) { + const id = `${record.tool}\u0000${record.key}`; + const held = winners.get(id); + if (held === undefined || compareWinner(record, held) < 0) winners.set(id, record); + } + return [...winners.entries()].sort(([a], [b]) => compareText(a, b)).map(([, record]) => record); +} diff --git a/cli/src/contexts/telemetry/domain/usage-record.ts b/cli/src/contexts/telemetry/domain/usage-record.ts new file mode 100644 index 000000000..017a4018e --- /dev/null +++ b/cli/src/contexts/telemetry/domain/usage-record.ts @@ -0,0 +1,41 @@ +export type UsageTool = "claude-code" | "codex"; + +export type UsageAgent = "main" | "subagent" | "advisor"; + +/** One billed model call. Facts only, in the tool's own terms; an unknown value is `null`, + * never `0`. `(tool, key)` is stable across re-reads, so storing it is idempotent. */ +export interface UsageRecord { + readonly tool: UsageTool; + readonly tool_version: string | null; + readonly key: string; + readonly session_id: string; + readonly agent: UsageAgent; + /** ISO-8601 UTC. */ + readonly at: string; + readonly model: string | null; + /** Excludes any cache read or write. */ + readonly input: number | null; + /** Includes reasoning. */ + readonly output: number | null; + readonly cache_read: number | null; + /** All TTLs. */ + readonly cache_write: number | null; + /** Of which one-hour TTL; `null` when the tool does not say. */ + readonly cache_write_1h: number | null; + /** A subset of `output`; `null` when the tool does not say. */ + readonly reasoning: number | null; + /** Raw, local only. */ + readonly cwd: string | null; + /** Raw, data only, never an attribution source. */ + readonly git_branch: string | null; +} + +/** What the fold ranks snapshots of one call by: a streamed call only ever grows. */ +export function usageTotal(record: UsageRecord): number { + return ( + (record.input ?? 0) + + (record.output ?? 0) + + (record.cache_read ?? 0) + + (record.cache_write ?? 0) + ); +} diff --git a/cli/src/contexts/telemetry/infrastructure/claude-transcript-source-adapter.ts b/cli/src/contexts/telemetry/infrastructure/claude-transcript-source-adapter.ts new file mode 100644 index 000000000..d25f18532 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/claude-transcript-source-adapter.ts @@ -0,0 +1,73 @@ +import { constants, type Stats } from "node:fs"; +import { open, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { isErrnoException } from "../../../kernel/reading/json-file.js"; +import type { TranscriptRead, TranscriptSource } from "../domain/ports/transcript-source.js"; +import { resumeOffset, type TranscriptPosition } from "../domain/transcript-position.js"; + +const NEWLINE = 0x0a; +const TRANSCRIPT_NAME = /\.jsonl(\.superseded-.+)?$/; + +function absentIsNothing(error: unknown): null { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw error; +} + +/** Device and inode name the file, not its path. Where the inode is not meaningful the birth + * time stands in for it. */ +function identityOf(stats: Stats): string { + return stats.ino === 0 ? `${stats.dev}:${stats.birthtimeMs}` : `${stats.dev}:${stats.ino}`; +} + +/** Reads the transcripts Claude Code keeps under `/projects`. */ +export class ClaudeTranscriptSourceAdapter implements TranscriptSource { + constructor(private readonly projectsRoot: string) {} + + async list(): Promise { + const found: string[] = []; + await this.walk(this.projectsRoot, found); + return found.sort(); + } + + private async walk(dir: string, found: string[]): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(absentIsNothing); + if (entries === null) return; + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) await this.walk(path, found); + else if (entry.isFile() && TRANSCRIPT_NAME.test(entry.name)) found.push(path); + } + } + + async read(path: string, since: TranscriptPosition | null): Promise { + const handle = await open(path, constants.O_RDONLY).catch(absentIsNothing); + if (handle === null) { + return { + lines: [], + position: since ?? { offset: 0, size: 0, identity: "" }, + restarted: false, + }; + } + try { + const stats = await handle.stat(); + const now = { size: stats.size, identity: identityOf(stats) }; + const { offset, restarted } = resumeOffset(since, now); + const bytes = Buffer.alloc(Math.max(0, now.size - offset)); + const { bytesRead } = await handle.read(bytes, 0, bytes.length, offset); + const end = bytes.subarray(0, bytesRead).lastIndexOf(NEWLINE) + 1; + const lines = bytes + .subarray(0, end) + .toString("utf8") + .split("\n") + .slice(0, -1) + .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); + return { + lines, + position: { offset: offset + end, size: now.size, identity: now.identity }, + restarted, + }; + } finally { + await handle.close(); + } + } +} diff --git a/cli/tests/architecture/context-boundary.arch.test.ts b/cli/tests/architecture/context-boundary.arch.test.ts index 0fc3023ae..30f7a98c0 100644 --- a/cli/tests/architecture/context-boundary.arch.test.ts +++ b/cli/tests/architecture/context-boundary.arch.test.ts @@ -102,6 +102,20 @@ const PUBLIC_MODULES: Readonly> = { // marketplace and this context forgets the plugins that came from it "src/contexts/framework/application/flows/marketplace-remove-use-case.ts", ], + // Measured with the composition root excluded: the adapter is wired from `runtime/wiring/` + // alone, so it stays internal. + telemetry: [ + // the common record every tool's reader produces, and the fold that makes it countable once + "src/contexts/telemetry/domain/usage-record.ts", + "src/contexts/telemetry/domain/usage-fold.ts", + // where a transcript root is, and where a read of one stopped + "src/contexts/telemetry/domain/claude-projects-root.ts", + "src/contexts/telemetry/domain/transcript-position.ts", + // the port its callers hold, so they can be given an implementation + "src/contexts/telemetry/domain/ports/transcript-source.ts", + // the operation another layer asks for + "src/contexts/telemetry/application/read-claude-usage-use-case.ts", + ], }; function contextsOnDisk(files: readonly string[]): string[] { diff --git a/cli/tests/architecture/tool-addition-cost.arch.test.ts b/cli/tests/architecture/tool-addition-cost.arch.test.ts index 292512f46..b6213eb86 100644 --- a/cli/tests/architecture/tool-addition-cost.arch.test.ts +++ b/cli/tests/architecture/tool-addition-cost.arch.test.ts @@ -78,6 +78,11 @@ const BASELINE: readonly { readonly path: string; readonly named: number }[] = [ path: "src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts", named: 3, }, + // The record's tool vocabulary is the contract's: `codex` is admitted so a second tool's + // reader needs no change to the record, and is not a list a new tool joins. + { path: "src/contexts/telemetry/domain/usage-record.ts", named: 1 }, + // Where Claude Code keeps its transcripts, named for the one tool whose directory it is. + { path: "src/contexts/telemetry/domain/claude-projects-root.ts", named: 1 }, // Cursor's project hooks file, named after the tool whose file it is: the directory it // writes into is Cursor's own, not a list a sixth tool joins. { path: "src/contexts/tools/domain/formats/cursor-hooks-project-merge.ts", named: 1 }, diff --git a/cli/tests/contexts/telemetry/application/read-claude-usage-use-case.integration.test.ts b/cli/tests/contexts/telemetry/application/read-claude-usage-use-case.integration.test.ts new file mode 100644 index 000000000..05c31e4f2 --- /dev/null +++ b/cli/tests/contexts/telemetry/application/read-claude-usage-use-case.integration.test.ts @@ -0,0 +1,148 @@ +import { appendFile, cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ReadClaudeUsageUseCase } from "../../../../src/contexts/telemetry/application/read-claude-usage-use-case.js"; +import type { TranscriptSource } from "../../../../src/contexts/telemetry/domain/ports/transcript-source.js"; +import { ClaudeTranscriptSourceAdapter } from "../../../../src/contexts/telemetry/infrastructure/claude-transcript-source-adapter.js"; + +const FIXTURES = resolve(import.meta.dirname, "../../../fixtures/claude-usage/projects"); + +let root: string; +let projects: string; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-claude-usage-")); + projects = join(root, "projects"); + await cp(FIXTURES, projects, { recursive: true }); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +function reversed(source: TranscriptSource): TranscriptSource { + return { + list: async () => [...(await source.list())].reverse(), + read: (path, since) => source.read(path, since), + }; +} + +describe("reading Claude usage from the fixture transcripts", () => { + it("yields exactly the expected records, field for field", async () => { + const reading = await new ReadClaudeUsageUseCase( + new ClaudeTranscriptSourceAdapter(projects) + ).execute(new Map()); + + const summary = reading.records.map((r) => ({ + key: r.key, + agent: r.agent, + session: r.session_id, + model: r.model, + tokens: [r.input, r.output, r.cache_read, r.cache_write, r.cache_write_1h], + })); + expect(summary).toEqual([ + { + key: "msg_A:req_A", + agent: "main", + session: "s-1", + model: "claude-opus-5-5", + tokens: [3, 250, 1000, 200, 200], + }, + { + key: "msg_B:req_B", + agent: "main", + session: "s-1", + model: "claude-opus-5-5", + tokens: [4, 300, 5000, 2283, 2283], + }, + { + key: "msg_B:req_B#advisor0", + agent: "advisor", + session: "s-1", + model: "claude-fable-5-1", + tokens: [90000, 700, 0, 0, null], + }, + { + key: "msg_C:req_C", + agent: "subagent", + session: "s-1", + model: "claude-sonnet-5-5", + tokens: [5, 40, 300, 60, 0], + }, + { + key: "msg_D:req_D", + agent: "main", + session: "s-0", + model: "claude-opus-5-5", + tokens: [7, 70, 0, 10, 0], + }, + ]); + expect(reading.unrecognised).toEqual([]); + }); + + it("gives identical records when the files are read in the opposite order", async () => { + const source = new ClaudeTranscriptSourceAdapter(projects); + const forward = await new ReadClaudeUsageUseCase(source).execute(new Map()); + const backward = await new ReadClaudeUsageUseCase(reversed(source)).execute(new Map()); + expect(backward.records).toEqual(forward.records); + }); + + it("folds a key across files: the resumed copy never beats the larger original", async () => { + const reading = await new ReadClaudeUsageUseCase( + new ClaudeTranscriptSourceAdapter(projects) + ).execute(new Map()); + const a = reading.records.find((r) => r.key === "msg_A:req_A"); + expect([a?.session_id, a?.output]).toEqual(["s-1", 250]); + expect(reading.records.filter((r) => r.key === "msg_A:req_A")).toHaveLength(1); + }); + + it("reports an unrecognised line and does not turn it into a record", async () => { + await appendFile( + join(projects, "proj-a", "s-2.jsonl"), + `${JSON.stringify({ type: "assistant", message: { usage: {} } })}\n` + ); + const reading = await new ReadClaudeUsageUseCase( + new ClaudeTranscriptSourceAdapter(projects) + ).execute(new Map()); + expect(reading.unrecognised).toHaveLength(1); + expect(reading.records).toHaveLength(5); + }); + + it("prefixes each unrecognised shape with its file", async () => { + const file = join(projects, "proj-a", "s-2.jsonl"); + await appendFile(file, "{broken\n"); + const reading = await new ReadClaudeUsageUseCase( + new ClaudeTranscriptSourceAdapter(projects) + ).execute(new Map()); + expect(reading.unrecognised).toEqual([`${file}: a complete line is not JSON`]); + }); + + it("continues from the positions it returned and reads only what was appended", async () => { + const source = new ClaudeTranscriptSourceAdapter(projects); + const useCase = new ReadClaudeUsageUseCase(source); + const first = await useCase.execute(new Map()); + await mkdir(join(projects, "proj-b"), { recursive: true }); + await writeFile( + join(projects, "proj-b", "s-5.jsonl"), + `${JSON.stringify({ + type: "assistant", + sessionId: "s-5", + requestId: "req_E", + timestamp: "2026-10-08T10:00:00.000Z", + message: { + id: "msg_E", + model: "m", + usage: { + input_tokens: 1, + output_tokens: 2, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 4, + }, + }, + })}\n` + ); + const second = await useCase.execute(first.positions); + expect(second.records.map((r) => r.key)).toEqual(["msg_E:req_E"]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/claude-projects-root.unit.test.ts b/cli/tests/contexts/telemetry/domain/claude-projects-root.unit.test.ts new file mode 100644 index 000000000..7fd979fb2 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/claude-projects-root.unit.test.ts @@ -0,0 +1,17 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { claudeProjectsRoot } from "../../../../src/contexts/telemetry/domain/claude-projects-root.js"; + +describe("where Claude Code keeps its transcripts", () => { + it("is projects/ under the configured directory when there is one", () => { + expect(claudeProjectsRoot("/cfg/claude", "/home/p")).toBe(join("/cfg/claude", "projects")); + }); + + it("is projects/ under .claude in the home directory otherwise", () => { + expect(claudeProjectsRoot(undefined, "/home/p")).toBe(join("/home/p", ".claude", "projects")); + }); + + it("reads an empty configured directory as none", () => { + expect(claudeProjectsRoot("", "/home/p")).toBe(join("/home/p", ".claude", "projects")); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts new file mode 100644 index 000000000..dd88c81cc --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest"; +import { readClaudeUsageLine } from "../../../../../src/contexts/telemetry/domain/formats/claude-transcript-usage.js"; + +const USAGE = { + input_tokens: 3, + output_tokens: 250, + cache_read_input_tokens: 1000, + cache_creation_input_tokens: 200, + cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 200 }, +}; + +function line( + overrides: Record = {}, + message: Record = {} +): string { + return JSON.stringify({ + type: "assistant", + sessionId: "s-1", + requestId: "req_A", + timestamp: "2026-10-07T10:00:01.000Z", + version: "2.1.292", + cwd: "/work/repo-wt-a", + gitBranch: "feat/a", + message: { id: "msg_A", model: "claude-opus-5-5", usage: USAGE, ...message }, + ...overrides, + }); +} + +function only(text: string) { + const { records } = readClaudeUsageLine(text); + expect(records).toHaveLength(1); + return records[0] as (typeof records)[number]; +} + +describe("one transcript line to usage records", () => { + it("maps every contract field of a plain call", () => { + expect(only(line())).toEqual({ + tool: "claude-code", + tool_version: "2.1.292", + key: "msg_A:req_A", + session_id: "s-1", + agent: "main", + at: "2026-10-07T10:00:01.000Z", + model: "claude-opus-5-5", + input: 3, + output: 250, + cache_read: 1000, + cache_write: 200, + cache_write_1h: 200, + reasoning: null, + cwd: "/work/repo-wt-a", + git_branch: "feat/a", + }); + }); + + it("keys a call by message id and request id", () => { + expect(only(line()).key).toBe("msg_A:req_A"); + }); + + it("keys a requestless call by message id, session id and timestamp", () => { + const requestless = line({ requestId: undefined }); + expect(only(requestless).key).toBe("msg_A:s-1:2026-10-07T10:00:01.000Z"); + }); + + it("drops the synthetic model", () => { + const synthetic = line({}, { model: "" }); + expect(readClaudeUsageLine(synthetic)).toEqual({ records: [], unrecognised: [] }); + }); + + it("calls a sidechain line a subagent", () => { + expect(only(line({ isSidechain: true })).agent).toBe("subagent"); + }); + + it("calls a line carrying an agent id a subagent", () => { + expect(only(line({ agentId: "x" })).agent).toBe("subagent"); + }); + + it("keeps a main line main when isSidechain is false and no agent id", () => { + expect(only(line({ isSidechain: false })).agent).toBe("main"); + }); + + it("sums cache writes and their one-hour part over the message iterations", () => { + const usage = { + ...USAGE, + cache_creation_input_tokens: 2283, + iterations: [ + { + type: "message", + cache_creation_input_tokens: 1436, + cache_creation: { ephemeral_1h_input_tokens: 1436 }, + }, + { type: "advisor_message", model: "claude-fable-5-1", input_tokens: 9 }, + { + type: "message", + cache_creation_input_tokens: 847, + cache_creation: { ephemeral_1h_input_tokens: 847 }, + }, + ], + }; + const record = readClaudeUsageLine(line({}, { usage })).records[0]; + expect(record?.cache_write).toBe(2283); + expect(record?.cache_write_1h).toBe(2283); + }); + + it("does not take the top-level cache write when iterations disagree with it", () => { + const usage = { + ...USAGE, + cache_creation_input_tokens: 5, + iterations: [ + { + type: "message", + cache_creation_input_tokens: 10, + cache_creation: { ephemeral_1h_input_tokens: 4 }, + }, + { + type: "message", + cache_creation_input_tokens: 30, + cache_creation: { ephemeral_1h_input_tokens: 6 }, + }, + ], + }; + const record = readClaudeUsageLine(line({}, { usage })).records[0]; + expect(record?.cache_write).toBe(40); + expect(record?.cache_write_1h).toBe(10); + }); + + it("leaves the one-hour part unknown when any iteration lacks its split", () => { + const usage = { + ...USAGE, + iterations: [ + { + type: "message", + cache_creation_input_tokens: 10, + cache_creation: { ephemeral_1h_input_tokens: 4 }, + }, + { type: "message", cache_creation_input_tokens: 30 }, + ], + }; + const record = readClaudeUsageLine(line({}, { usage })).records[0]; + expect(record?.cache_write).toBe(40); + expect(record?.cache_write_1h).toBeNull(); + }); + + it("makes each advisor iteration its own record with its own model and key", () => { + const usage = { + ...USAGE, + iterations: [ + { + type: "message", + cache_creation_input_tokens: 200, + cache_creation: { ephemeral_1h_input_tokens: 200 }, + }, + { + type: "advisor_message", + model: "claude-fable-5-1", + input_tokens: 90000, + output_tokens: 700, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + { type: "advisor_message", model: "claude-fable-5-2", input_tokens: 1, output_tokens: 2 }, + ], + }; + const { records } = readClaudeUsageLine(line({}, { usage })); + expect(records.map((r) => [r.key, r.agent, r.model])).toEqual([ + ["msg_A:req_A", "main", "claude-opus-5-5"], + ["msg_A:req_A#advisor0", "advisor", "claude-fable-5-1"], + ["msg_A:req_A#advisor1", "advisor", "claude-fable-5-2"], + ]); + expect(records[1]).toMatchObject({ + input: 90000, + output: 700, + cache_write: 0, + cache_write_1h: null, + }); + }); + + it("reports a usage line of an unexpected shape and guesses no record", () => { + const noId = line({}, { id: undefined }); + const outcome = readClaudeUsageLine(noId); + expect(outcome.records).toEqual([]); + expect(outcome.unrecognised).toHaveLength(1); + }); + + it("reports a complete line that is not JSON", () => { + const outcome = readClaudeUsageLine("{not json"); + expect(outcome.records).toEqual([]); + expect(outcome.unrecognised).toHaveLength(1); + }); + + it("skips a line that carries no usage without reporting it", () => { + expect(readClaudeUsageLine(JSON.stringify({ type: "user", message: {} }))).toEqual({ + records: [], + unrecognised: [], + }); + }); + + it("leaves a missing counter unknown, never zero, and reports the shape", () => { + const usage = { input_tokens: 3, cache_read_input_tokens: 1, cache_creation_input_tokens: 2 }; + const outcome = readClaudeUsageLine(line({}, { usage })); + expect(outcome.records[0]?.output).toBeNull(); + expect(outcome.records[0]?.input).toBe(3); + expect(outcome.unrecognised).toHaveLength(1); + }); + + it("leaves a counter that is not a non-negative integer unknown", () => { + const usage = { ...USAGE, output_tokens: "250" }; + expect(readClaudeUsageLine(line({}, { usage })).records[0]?.output).toBeNull(); + }); + + it("names why a complete line that is not JSON is unrecognised", () => { + expect(readClaudeUsageLine("{not json").unrecognised).toEqual(["a complete line is not JSON"]); + }); + + it("names why a JSON line that is no object is unrecognised", () => { + expect(readClaudeUsageLine("[1]").unrecognised).toEqual(["a line is not an object"]); + }); + + it("names why a usage that is no object is unrecognised", () => { + expect(readClaudeUsageLine(line({}, { usage: 7 })).unrecognised).toEqual([ + "message.usage is not an object", + ]); + }); + + it("names why a usage line without identity is unrecognised", () => { + expect(readClaudeUsageLine(line({ sessionId: undefined })).unrecognised).toEqual([ + "a usage line lacks message.id, sessionId or timestamp", + ]); + expect(readClaudeUsageLine(line({ timestamp: undefined })).records).toEqual([]); + }); + + it("names each counter a usage line is missing", () => { + const usage = { cache_creation_input_tokens: 2 }; + expect(readClaudeUsageLine(line({}, { usage })).unrecognised).toEqual([ + "usage.input_tokens is missing or not a count", + "usage.output_tokens is missing or not a count", + "usage.cache_read_input_tokens is missing or not a count", + ]); + }); + + it("names each counter an advisor iteration is missing", () => { + const usage = { ...USAGE, iterations: [{ type: "advisor_message", model: "m" }] }; + expect(readClaudeUsageLine(line({}, { usage })).unrecognised).toEqual([ + "advisor.input_tokens is missing or not a count", + "advisor.output_tokens is missing or not a count", + "advisor.cache_read_input_tokens is missing or not a count", + "advisor.cache_creation_input_tokens is missing or not a count", + ]); + }); + + it("reads a negative, fractional or non-finite counter as unknown", () => { + for (const bad of [-1, 1.5, Number.NaN, null]) { + const usage = { ...USAGE, input_tokens: bad }; + expect(readClaudeUsageLine(line({}, { usage })).records[0]?.input).toBeNull(); + } + }); + + it("keeps a zero counter as zero", () => { + const usage = { ...USAGE, output_tokens: 0 }; + expect(readClaudeUsageLine(line({}, { usage })).records[0]?.output).toBe(0); + }); + + it("reads an empty request id as none", () => { + expect(only(line({ requestId: "" })).key).toBe("msg_A:s-1:2026-10-07T10:00:01.000Z"); + }); + + it("leaves a model that is not text unknown", () => { + expect(only(line({}, { model: 5 })).model).toBeNull(); + }); + + it("skips an assistant line whose message carries no usage", () => { + expect(readClaudeUsageLine(line({}, { usage: undefined }))).toEqual({ + records: [], + unrecognised: [], + }); + expect(readClaudeUsageLine(line({ message: "text" }))).toEqual({ + records: [], + unrecognised: [], + }); + }); + + it("skips a line of another type even when it carries a usage", () => { + expect(readClaudeUsageLine(line({ type: "user" }))).toEqual({ records: [], unrecognised: [] }); + }); + + it("falls back to the top-level counters and reports iterations that are not a list", () => { + const usage = { ...USAGE, iterations: "nope" }; + const outcome = readClaudeUsageLine(line({}, { usage })); + expect(outcome.records[0]?.cache_write).toBe(200); + expect(outcome.unrecognised).toEqual(["usage.iterations is not a list"]); + }); + + it("reports an iteration that is not an object and keeps the others", () => { + const usage = { + ...USAGE, + iterations: [ + 7, + { + type: "message", + cache_creation_input_tokens: 9, + cache_creation: { ephemeral_1h_input_tokens: 9 }, + }, + ], + }; + const outcome = readClaudeUsageLine(line({}, { usage })); + expect(outcome.records[0]?.cache_write).toBe(9); + expect(outcome.unrecognised).toEqual(["usage.iterations holds a non-object"]); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/transcript-position.unit.test.ts b/cli/tests/contexts/telemetry/domain/transcript-position.unit.test.ts new file mode 100644 index 000000000..bd0bb9948 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/transcript-position.unit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resumeOffset } from "../../../../src/contexts/telemetry/domain/transcript-position.js"; + +const SINCE = { offset: 90, size: 100, identity: "1:42" }; + +describe("where a transcript is read from", () => { + it("reads a file never read before from its start", () => { + expect(resumeOffset(null, { size: 100, identity: "1:42" })).toEqual({ + offset: 0, + restarted: false, + }); + }); + + it("resumes at the position when the file only grew", () => { + expect(resumeOffset(SINCE, { size: 150, identity: "1:42" })).toEqual({ + offset: 90, + restarted: false, + }); + }); + + it("resumes at the position when the file is unchanged", () => { + expect(resumeOffset(SINCE, { size: 100, identity: "1:42" })).toEqual({ + offset: 90, + restarted: false, + }); + }); + + it("reads a shrunk file whole", () => { + expect(resumeOffset(SINCE, { size: 60, identity: "1:42" })).toEqual({ + offset: 0, + restarted: true, + }); + }); + + it("reads a replaced file whole even when it is larger", () => { + expect(resumeOffset(SINCE, { size: 500, identity: "1:77" })).toEqual({ + offset: 0, + restarted: true, + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/usage-fold.unit.test.ts b/cli/tests/contexts/telemetry/domain/usage-fold.unit.test.ts new file mode 100644 index 000000000..5dc936b44 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/usage-fold.unit.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { foldUsage } from "../../../../src/contexts/telemetry/domain/usage-fold.js"; +import { + type UsageRecord, + usageTotal, +} from "../../../../src/contexts/telemetry/domain/usage-record.js"; + +function record(overrides: Partial = {}): UsageRecord { + return { + tool: "claude-code", + tool_version: null, + key: "msg_A:req_A", + session_id: "s-1", + agent: "main", + at: "2026-10-07T10:00:00.000Z", + model: "m", + input: 1, + output: 10, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: null, + git_branch: null, + ...overrides, + }; +} + +function permutations(items: readonly T[]): T[][] { + if (items.length <= 1) return [[...items]]; + return items.flatMap((item, i) => + permutations([...items.slice(0, i), ...items.slice(i + 1)]).map((rest) => [item, ...rest]) + ); +} + +describe("the global fold keeps one record per key", () => { + it("keeps the record with the largest total", () => { + const snapshots = [record({ output: 10 }), record({ output: 250 }), record({ output: 40 })]; + expect(foldUsage(snapshots).map((r) => r.output)).toEqual([250]); + }); + + it("breaks a tie on total by the earliest time", () => { + const late = record({ at: "2026-10-07T10:00:09.000Z", session_id: "s-0" }); + const early = record({ at: "2026-10-07T10:00:01.000Z", session_id: "s-9" }); + expect(foldUsage([late, early]).map((r) => r.session_id)).toEqual(["s-9"]); + }); + + it("breaks a tie on total and time by the lowest session id", () => { + const high = record({ session_id: "s-2" }); + const low = record({ session_id: "s-1" }); + expect(foldUsage([high, low]).map((r) => r.session_id)).toEqual(["s-1"]); + }); + + it("compares times as instants, not as text", () => { + const early = record({ at: "2026-10-07T10:00:00.000Z", session_id: "s-9" }); + const late = record({ at: "2026-10-07T09:30:00.000-01:00", session_id: "s-0" }); + expect(foldUsage([late, early]).map((r) => r.session_id)).toEqual(["s-9"]); + }); + + it("gives the same records whatever the order of the input", () => { + const input = [ + record({ output: 10, session_id: "s-1" }), + record({ output: 250, session_id: "s-2", at: "2026-10-07T10:00:05.000Z" }), + record({ output: 250, session_id: "s-3", at: "2026-10-07T10:00:03.000Z" }), + record({ key: "msg_B:req_B", output: 5 }), + record({ key: "msg_B:req_B#advisor0", agent: "advisor", output: 7 }), + ]; + const expected = JSON.stringify(foldUsage(input)); + for (const order of permutations(input)) + expect(JSON.stringify(foldUsage(order))).toBe(expected); + expect(foldUsage(input).map((r) => [r.key, r.session_id])).toEqual([ + ["msg_A:req_A", "s-3"], + ["msg_B:req_B", "s-1"], + ["msg_B:req_B#advisor0", "s-1"], + ]); + }); + + it("picks the same record when only unranked fields differ, whatever the order", () => { + const here = record({ cwd: "/a" }); + const there = record({ cwd: "/b" }); + expect(foldUsage([here, there])).toEqual(foldUsage([there, here])); + }); + + it("breaks a tie on time by text when a time does not parse", () => { + const high = record({ at: "later", session_id: "s-1" }); + const low = record({ at: "earlier", session_id: "s-2" }); + expect(foldUsage([high, low]).map((r) => r.at)).toEqual(["earlier"]); + expect(foldUsage([low, high]).map((r) => r.at)).toEqual(["earlier"]); + }); + + it("falls to the session id when two unparseable times are the same text", () => { + const high = record({ at: "n/a", session_id: "s-2" }); + const low = record({ at: "n/a", session_id: "s-1" }); + expect(foldUsage([high, low]).map((r) => r.session_id)).toEqual(["s-1"]); + }); + + it("sums all four counters into the total", () => { + expect(usageTotal(record({ input: 1, output: 2, cache_read: 4, cache_write: 8 }))).toBe(15); + }); + + it("keeps the same key of two tools apart", () => { + const folded = foldUsage([record(), record({ tool: "codex" })]); + expect(folded).toHaveLength(2); + }); + + it("counts an unknown counter as nothing in the total", () => { + expect(usageTotal(record({ input: null, output: 4, cache_read: null, cache_write: 6 }))).toBe( + 10 + ); + }); +}); diff --git a/cli/tests/contexts/telemetry/infrastructure/claude-transcript-source-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/claude-transcript-source-adapter.integration.test.ts new file mode 100644 index 000000000..6b4e9e5a5 --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/claude-transcript-source-adapter.integration.test.ts @@ -0,0 +1,155 @@ +import { appendFile, cp, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ClaudeTranscriptSourceAdapter } from "../../../../src/contexts/telemetry/infrastructure/claude-transcript-source-adapter.js"; + +const FIXTURES = resolve(import.meta.dirname, "../../../fixtures/claude-usage/projects"); + +let root: string; +let projects: string; +let adapter: ClaudeTranscriptSourceAdapter; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-claude-transcripts-")); + projects = join(root, "projects"); + await mkdir(projects, { recursive: true }); + adapter = new ClaudeTranscriptSourceAdapter(projects); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("listing the transcripts under the projects root", () => { + it("finds main, resumed, sub-agent and set-aside transcripts", async () => { + await cp(FIXTURES, projects, { recursive: true }); + const found = (await adapter.list()).map((path) => + relative(projects, path).replaceAll("\\", "/") + ); + expect(found).toEqual([ + "proj-a/s-0.jsonl.superseded-1", + "proj-a/s-1.jsonl", + "proj-a/s-1/subagents/agent-x.jsonl", + "proj-a/s-2.jsonl", + ]); + }); + + it("walks sub-agent directories at any depth", async () => { + const deep = join(projects, "p", "s", "subagents", "workflows", "w"); + await mkdir(deep, { recursive: true }); + await writeFile(join(deep, "agent-y.jsonl"), ""); + expect(await adapter.list()).toEqual([join(deep, "agent-y.jsonl")]); + }); + + it("finds a set-aside whatever its suffix", async () => { + await mkdir(join(projects, "p"), { recursive: true }); + await writeFile(join(projects, "p", "s.jsonl.superseded-20261001T0102"), ""); + expect(await adapter.list()).toHaveLength(1); + }); + + it("fails on a projects root it cannot list, instead of reading it as empty", async () => { + const file = join(root, "not-a-directory"); + await writeFile(file, "x"); + await expect(new ClaudeTranscriptSourceAdapter(file).list()).rejects.toThrow(); + }); + + it("ignores files that are not transcripts", async () => { + await mkdir(join(projects, "p"), { recursive: true }); + await writeFile(join(projects, "p", "notes.txt"), "x"); + await writeFile(join(projects, "p", "s.jsonl.bak"), "x"); + expect(await adapter.list()).toEqual([]); + }); + + it("lists nothing when the projects root does not exist yet", async () => { + const absent = new ClaudeTranscriptSourceAdapter(join(root, "never")); + expect(await absent.list()).toEqual([]); + }); +}); + +describe("reading a transcript from a byte offset", () => { + it("reads every complete line of a file read for the first time", async () => { + const path = join(projects, "t.jsonl"); + await writeFile(path, "one\ntwo\n"); + const read = await adapter.read(path, null); + expect(read.lines).toEqual(["one", "two"]); + expect(read.position.offset).toBe(8); + expect(read.position.size).toBe(8); + expect(read.restarted).toBe(false); + }); + + it("reads only what was appended since the position", async () => { + const path = join(projects, "t.jsonl"); + await writeFile(path, "one\n"); + const first = await adapter.read(path, null); + await appendFile(path, "two\nthree\n"); + const second = await adapter.read(path, first.position); + expect(second.lines).toEqual(["two", "three"]); + expect(second.position.offset).toBe(14); + }); + + it("does not consume an unterminated last line, and delivers it once finished", async () => { + const path = join(projects, "t.jsonl"); + await writeFile(path, "one\ntw"); + const first = await adapter.read(path, null); + expect(first.lines).toEqual(["one"]); + expect(first.position.offset).toBe(4); + expect(first.position.size).toBe(6); + await appendFile(path, "o\n"); + const second = await adapter.read(path, first.position); + expect(second.lines).toEqual(["two"]); + }); + + it("counts offsets in bytes, not characters", async () => { + const path = join(projects, "t.jsonl"); + await writeFile(path, "é\nb\n"); + const first = await adapter.read(path, null); + expect(first.position.offset).toBe(5); + await appendFile(path, "c\n"); + expect((await adapter.read(path, first.position)).lines).toEqual(["c"]); + }); + + it("strips the carriage return of a CRLF line", async () => { + const path = join(projects, "t.jsonl"); + await writeFile(path, "one\r\ntwo\r\n"); + expect((await adapter.read(path, null)).lines).toEqual(["one", "two"]); + }); + + it("reads a truncated file whole", async () => { + const path = join(projects, "t.jsonl"); + await writeFile(path, "one\ntwo\nthree\n"); + const first = await adapter.read(path, null); + await writeFile(path, "a\n"); + const second = await adapter.read(path, first.position); + expect(second.lines).toEqual(["a"]); + expect(second.restarted).toBe(true); + }); + + it("reads a replaced file whole even when it is larger", async () => { + const path = join(projects, "t.jsonl"); + await writeFile(path, "one\n"); + const first = await adapter.read(path, null); + await writeFile(join(projects, "other.jsonl"), "x\ny\nz\nw\n"); + await rm(path); + await rename(join(projects, "other.jsonl"), path); + const second = await adapter.read(path, first.position); + expect(second.lines).toEqual(["x", "y", "z", "w"]); + expect(second.restarted).toBe(true); + }); + + it("hands a vanished file's position back unchanged", async () => { + const path = join(projects, "gone.jsonl"); + const since = { offset: 4, size: 4, identity: "1:2" }; + expect((await adapter.read(path, since)).position).toEqual(since); + expect((await adapter.read(path, null)).position).toEqual({ offset: 0, size: 0, identity: "" }); + }); + + it("fails on a transcript it cannot read, instead of reading it as empty", async () => { + await expect(adapter.read(projects, null)).rejects.toThrow(); + }); + + it("reads nothing from a file that vanished", async () => { + const read = await adapter.read(join(projects, "gone.jsonl"), null); + expect(read.lines).toEqual([]); + }); +}); diff --git a/cli/tests/fixtures/claude-usage/projects/proj-a/s-0.jsonl.superseded-1 b/cli/tests/fixtures/claude-usage/projects/proj-a/s-0.jsonl.superseded-1 new file mode 100644 index 000000000..818db9b67 --- /dev/null +++ b/cli/tests/fixtures/claude-usage/projects/proj-a/s-0.jsonl.superseded-1 @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"s-0","requestId":"req_D","timestamp":"2026-10-06T09:00:00.000Z","version":"2.1.290","cwd":"/work/repo-wt-b","gitBranch":"main","message":{"id":"msg_D","model":"claude-opus-5-5","usage":{"input_tokens":7,"output_tokens":70,"cache_read_input_tokens":0,"cache_creation_input_tokens":10,"cache_creation":{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":0}}}} diff --git a/cli/tests/fixtures/claude-usage/projects/proj-a/s-1.jsonl b/cli/tests/fixtures/claude-usage/projects/proj-a/s-1.jsonl new file mode 100644 index 000000000..69c2903d4 --- /dev/null +++ b/cli/tests/fixtures/claude-usage/projects/proj-a/s-1.jsonl @@ -0,0 +1,4 @@ +{"type":"assistant","sessionId":"s-1","requestId":"req_A","timestamp":"2026-10-07T10:00:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":10,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} +{"type":"assistant","sessionId":"s-1","requestId":"req_A","timestamp":"2026-10-07T10:00:01.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":250,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} +{"type":"assistant","sessionId":"s-1","requestId":"req_B","timestamp":"2026-10-07T10:01:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a/src","gitBranch":"feat/a","message":{"id":"msg_B","model":"claude-opus-5-5","usage":{"input_tokens":4,"output_tokens":300,"cache_read_input_tokens":5000,"cache_creation_input_tokens":2283,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1436},"iterations":[{"type":"message","input_tokens":2,"output_tokens":100,"cache_read_input_tokens":2500,"cache_creation_input_tokens":1436,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":1436}},{"type":"advisor_message","model":"claude-fable-5-1","input_tokens":90000,"output_tokens":700,"cache_read_input_tokens":0,"cache_creation_input_tokens":0},{"type":"message","input_tokens":2,"output_tokens":200,"cache_read_input_tokens":2500,"cache_creation_input_tokens":847,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":847}}]}}} +{"type":"assistant","sessionId":"s-1","requestId":"req_S","timestamp":"2026-10-07T10:02:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_S","model":"","usage":{"input_tokens":0,"output_tokens":0}}} diff --git a/cli/tests/fixtures/claude-usage/projects/proj-a/s-1/subagents/agent-x.jsonl b/cli/tests/fixtures/claude-usage/projects/proj-a/s-1/subagents/agent-x.jsonl new file mode 100644 index 000000000..86bd05984 --- /dev/null +++ b/cli/tests/fixtures/claude-usage/projects/proj-a/s-1/subagents/agent-x.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"s-1","agentId":"x","isSidechain":true,"requestId":"req_C","timestamp":"2026-10-07T10:03:00.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_C","model":"claude-sonnet-5-5","usage":{"input_tokens":5,"output_tokens":40,"cache_read_input_tokens":300,"cache_creation_input_tokens":60,"cache_creation":{"ephemeral_5m_input_tokens":60,"ephemeral_1h_input_tokens":0}}}} diff --git a/cli/tests/fixtures/claude-usage/projects/proj-a/s-2.jsonl b/cli/tests/fixtures/claude-usage/projects/proj-a/s-2.jsonl new file mode 100644 index 000000000..b13b25955 --- /dev/null +++ b/cli/tests/fixtures/claude-usage/projects/proj-a/s-2.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"s-2","requestId":"req_A","timestamp":"2026-10-07T10:00:01.000Z","version":"2.1.292","cwd":"/work/repo-wt-a","gitBranch":"feat/a","message":{"id":"msg_A","model":"claude-opus-5-5","usage":{"input_tokens":3,"output_tokens":10,"cache_read_input_tokens":1000,"cache_creation_input_tokens":200,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":200}}}} From a80242cc981563a514a81051fcd9b91e220b5f7d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 9 Oct 2026 19:28:38 +0200 Subject: [PATCH 05/73] feat(telemetry): store usage in a local ledger, one record per call Add `aidd telemetry ingest`: read what every Claude Code transcript gained and store each billed call of the projects that opted in. - ledger: /ledger/.jsonl by the record's own UTC time; a changed partition is rewritten by temp file and rename, so a better snapshot of a call replaces the held one even across months and no reader meets two live records or a torn line - idempotent: held records fold first, so an equal record changes nothing; the phase 3 fold decides every replacement, in any order - lock: ledger/.lock holds pid and creation time, created exclusively; a dead pid or a lock older than ten minutes is cleared, a live recent one is waited for and released only by its owner - repository: git rev-parse per working directory, id = sha256 of the normalised host/owner/repo of origin (https, ssh, scp-like, credentials, port and .git dropped), else the lowest root commit; a linked worktree resolves to its main repository - consent: .aidd/config.json telemetry.enabled with version 2; the previous bare enabled is not consent; an unparseable file is counted; the main working tree answers for a worktree without its own file; AIDD_TELEMETRY=0 refuses before anything is read or written - roots.json remembers each directory seen alive, so lines from a deleted one still resolve; one never seen alive is counted, not stored - bindings/branches.jsonl snapshots branch..aiddTask|Ticket|DeclaredAt with the branch's creation time from its reflog, only when it changed - write order under the lock: ledger, roots, snapshots, offsets last - owner-only storage: 0700/0600 on POSIX, icacls on Windows - coverage counters in the ingest result, by reason not stored Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- cli/mutation-scopes.json | 2 +- cli/src/cli.ts | 4 + .../application/directory-resolver.ts | 112 ++++ .../application/ingest-usage-use-case.ts | 139 +++++ .../application/snapshot-bindings-use-case.ts | 31 ++ .../telemetry/domain/branch-binding.ts | 159 ++++++ .../domain/ports/binding-snapshot-store.ts | 8 + .../domain/ports/branch-binding-source.ts | 9 + .../telemetry/domain/ports/consent-source.ts | 5 + .../telemetry/domain/ports/private-storage.ts | 10 + .../domain/ports/repository-locator.ts | 19 + .../domain/ports/resolution-store.ts | 8 + .../telemetry/domain/ports/usage-ledger.ts | 21 + .../telemetry/domain/repository-identity.ts | 46 ++ .../telemetry/domain/repository-resolution.ts | 54 ++ .../contexts/telemetry/domain/stored-usage.ts | 116 ++++ .../telemetry/domain/telemetry-consent.ts | 21 + .../contexts/telemetry/domain/text-order.ts | 5 + .../telemetry/domain/transcript-position.ts | 34 ++ .../contexts/telemetry/domain/usage-fold.ts | 11 +- .../contexts/telemetry/domain/usage-record.ts | 6 +- .../binding-snapshot-store-adapter.ts | 37 ++ .../infrastructure/consent-source-adapter.ts | 10 + .../git-branch-binding-source-adapter.ts | 42 ++ .../git-repository-locator-adapter.ts | 56 ++ .../telemetry/infrastructure/ledger-lock.ts | 124 +++++ .../resolution-store-adapter.ts | 30 ++ .../telemetry/infrastructure/run-git.ts | 23 + .../infrastructure/usage-ledger-adapter.ts | 94 ++++ cli/src/kernel/reading/json-file.ts | 12 + cli/src/kernel/reading/text-file.ts | 25 + cli/src/presentation/commands/telemetry.ts | 26 + .../presentation/display/telemetry-display.ts | 42 ++ .../filesystem/private-storage-adapter.ts | 71 +++ cli/src/runtime/wiring/framework.ts | 3 + cli/src/runtime/wiring/telemetry.ts | 62 +++ .../context-boundary.arch.test.ts | 6 + .../architecture/folder-size.arch.test.ts | 4 +- .../orchestrator-deps.arch.test.ts | 5 + .../ingest-usage-use-case.unit.test.ts | 500 ++++++++++++++++++ .../snapshot-bindings-use-case.unit.test.ts | 134 +++++ .../domain/branch-binding.unit.test.ts | 298 +++++++++++ .../domain/repository-identity.unit.test.ts | 105 ++++ .../domain/repository-resolution.unit.test.ts | 58 ++ .../domain/stored-usage.unit.test.ts | 196 +++++++ .../domain/telemetry-consent.unit.test.ts | 38 ++ .../telemetry/domain/text-order.unit.test.ts | 21 + .../domain/transcript-position.unit.test.ts | 54 +- ...snapshot-store-adapter.integration.test.ts | 81 +++ ...binding-source-adapter.integration.test.ts | 100 ++++ ...sitory-locator-adapter.integration.test.ts | 135 +++++ .../ledger-lock.integration.test.ts | 247 +++++++++ ...n-and-consent-adapters.integration.test.ts | 40 ++ .../run-git.integration.test.ts | 39 ++ .../usage-ledger-adapter.integration.test.ts | 228 ++++++++ cli/tests/e2e/telemetry-ingest.e2e.test.ts | 201 +++++++ cli/tests/golden/snapshots/help/surface.json | 12 +- cli/tests/helpers/git-sandbox.ts | 37 ++ .../helpers/ports/in-memory-telemetry.ts | 153 ++++++ .../kernel/reading/json-file.unit.test.ts | 17 +- cli/tests/kernel/text-file.unit.test.ts | 54 ++ ...rivate-storage-adapter.integration.test.ts | 99 ++++ 62 files changed, 4324 insertions(+), 15 deletions(-) create mode 100644 cli/src/contexts/telemetry/application/directory-resolver.ts create mode 100644 cli/src/contexts/telemetry/application/ingest-usage-use-case.ts create mode 100644 cli/src/contexts/telemetry/application/snapshot-bindings-use-case.ts create mode 100644 cli/src/contexts/telemetry/domain/branch-binding.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/binding-snapshot-store.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/branch-binding-source.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/consent-source.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/private-storage.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/repository-locator.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/resolution-store.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/usage-ledger.ts create mode 100644 cli/src/contexts/telemetry/domain/repository-identity.ts create mode 100644 cli/src/contexts/telemetry/domain/repository-resolution.ts create mode 100644 cli/src/contexts/telemetry/domain/stored-usage.ts create mode 100644 cli/src/contexts/telemetry/domain/telemetry-consent.ts create mode 100644 cli/src/contexts/telemetry/domain/text-order.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/consent-source-adapter.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/git-repository-locator-adapter.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/ledger-lock.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/run-git.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts create mode 100644 cli/src/kernel/reading/text-file.ts create mode 100644 cli/src/presentation/commands/telemetry.ts create mode 100644 cli/src/presentation/display/telemetry-display.ts create mode 100644 cli/src/runtime/filesystem/private-storage-adapter.ts create mode 100644 cli/src/runtime/wiring/telemetry.ts create mode 100644 cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/application/snapshot-bindings-use-case.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/repository-resolution.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/stored-usage.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/telemetry-consent.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/text-order.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/git-repository-locator-adapter.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/ledger-lock.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/resolution-and-consent-adapters.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/run-git.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/usage-ledger-adapter.integration.test.ts create mode 100644 cli/tests/e2e/telemetry-ingest.e2e.test.ts create mode 100644 cli/tests/helpers/git-sandbox.ts create mode 100644 cli/tests/helpers/ports/in-memory-telemetry.ts create mode 100644 cli/tests/kernel/text-file.unit.test.ts create mode 100644 cli/tests/runtime/filesystem/private-storage-adapter.integration.test.ts diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index 88b00550a..329832529 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -19,7 +19,7 @@ }, "telemetry": { "mutate": "src/contexts/telemetry/**/*.ts", - "break": 94 + "break": 96 }, "translate": { "mutate": "src/contexts/translate/**/*.ts", diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 399853748..945e7a7d1 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -9,6 +9,7 @@ import { runMenuLoop } from "./presentation/commands/menu.js"; import { registerPluginCommand } from "./presentation/commands/plugin.js"; import { registerSetupCommand } from "./presentation/commands/setup.js"; import { registerSyncCommand } from "./presentation/commands/sync.js"; +import { registerTelemetryCommand } from "./presentation/commands/telemetry.js"; import { registerTranslateCommand } from "./presentation/commands/translate.js"; import { registerUpdateCommand } from "./presentation/commands/update.js"; import { CLIOutput } from "./presentation/output.js"; @@ -39,6 +40,7 @@ registerSyncCommand(program); registerUpdateCommand(program); registerDoctorCommand(program); registerCleanCommand(program); +registerTelemetryCommand(program); // Commands already paying for network I/O, so the update-check refresh rides one of them. // `marketplace remove` is offline and `update` already resolves the latest version itself. @@ -52,6 +54,8 @@ const ONLINE_COMMAND_PATHS = new Set([ program.hook("preAction", async (_thisCommand, actionCommand) => { if (process.env.AIDD_SKIP_UPDATE_CHECK === "1") return; + // A hook runs `telemetry ingest --quiet`; an update notice would break its silence. + if (resolveCommandPath(actionCommand).startsWith("telemetry")) return; const opts = program.opts<{ verbose?: boolean }>(); const output = new CLIOutput(opts.verbose ?? false); const deps = await createDeps(process.cwd(), { verbose: opts.verbose ?? false }, output).catch( diff --git a/cli/src/contexts/telemetry/application/directory-resolver.ts b/cli/src/contexts/telemetry/application/directory-resolver.ts new file mode 100644 index 000000000..95ae30353 --- /dev/null +++ b/cli/src/contexts/telemetry/application/directory-resolver.ts @@ -0,0 +1,112 @@ +import type { ConsentSource } from "../domain/ports/consent-source.js"; +import type { RepositoryLocator } from "../domain/ports/repository-locator.js"; +import type { ResolutionStore } from "../domain/ports/resolution-store.js"; +import { repositoryIdOf } from "../domain/repository-identity.js"; +import { + cwdKey, + type NotStoredReason, + type RepositoryResolution, +} from "../domain/repository-resolution.js"; +import { type Consent, consentOf } from "../domain/telemetry-consent.js"; + +export type DirectoryOutcome = + | { + readonly stored: RepositoryResolution; + /** Resolved from the directory as it stands, not from what was remembered of it. */ + readonly live: boolean; + } + | { readonly skipped: NotStoredReason }; + +function sameResolution(a: RepositoryResolution | undefined, b: RepositoryResolution): boolean { + return ( + a !== undefined && + a.repository_id === b.repository_id && + a.root === b.root && + a.consented === b.consented + ); +} + +/** Ties a working directory to a repository and to that project's consent. A directory that + * exists is resolved as it stands, so a project that opts out is not measured on the strength + * of an earlier look; one that is gone is resolved from what was remembered of it, and one that + * was never seen alive cannot be proved to have consented. */ +export class DirectoryResolver { + constructor( + private readonly locator: RepositoryLocator, + private readonly consentSource: ConsentSource, + private readonly store: ResolutionStore, + private readonly caseInsensitiveFileSystem: boolean + ) {} + + /** One run: a directory is looked at once, however many calls it made. */ + async open(): Promise { + return new ResolutionRun( + this.locator, + this.consentSource, + this.store, + await this.store.load(), + this.caseInsensitiveFileSystem + ); + } +} + +export class ResolutionRun { + private changed = false; + private readonly outcomes = new Map(); + private readonly consents = new Map(); + + constructor( + private readonly locator: RepositoryLocator, + private readonly consentSource: ConsentSource, + private readonly store: ResolutionStore, + private readonly remembered: Map, + private readonly caseInsensitiveFileSystem: boolean + ) {} + + /** Keeps what this run learned. */ + async close(): Promise { + if (this.changed) await this.store.save(this.remembered); + } + + async resolve(cwd: string): Promise { + const held = this.outcomes.get(cwd); + if (held !== undefined) return held; + const outcome = await this.look(cwd); + this.outcomes.set(cwd, outcome); + return outcome; + } + + private async look(cwd: string): Promise { + const key = cwdKey(cwd, this.caseInsensitiveFileSystem); + const located = await this.locator.locate(cwd); + if (located.status === "gone") { + const earlier = this.remembered.get(key); + if (earlier === undefined) return { skipped: "never-seen-alive" }; + return earlier.consented ? { stored: earlier, live: false } : { skipped: "no-consent" }; + } + if (located.status === "outside-repository") return { skipped: "outside-repo" }; + // A repository with no origin and no commit has nothing to be named by. + const id = repositoryIdOf(located); + if (id === null) return { skipped: "outside-repo" }; + const consent = await this.consentOfRoot(located.root, located.mainRoot); + const resolution = { repository_id: id, root: located.root, consented: consent === "granted" }; + if (!sameResolution(this.remembered.get(key), resolution)) { + this.remembered.set(key, resolution); + this.changed = true; + } + if (consent === "granted") return { stored: resolution, live: true }; + return { skipped: consent === "unreadable" ? "unreadable-consent" : "no-consent" }; + } + + /** A linked worktree often carries no `.aidd/config.json` of its own, the file being + * untracked, so the main working tree's answers for it. */ + private async consentOfRoot(root: string, mainRoot: string): Promise { + const held = this.consents.get(root); + if (held !== undefined) return held; + const own = await this.consentSource.read(root); + const text = own === null && mainRoot !== root ? await this.consentSource.read(mainRoot) : own; + const consent = consentOf(text); + this.consents.set(root, consent); + return consent; + } +} diff --git a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts new file mode 100644 index 000000000..02b417fb0 --- /dev/null +++ b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts @@ -0,0 +1,139 @@ +import type { UsageLedger } from "../domain/ports/usage-ledger.js"; +import type { NotStoredReason } from "../domain/repository-resolution.js"; +import { monthOf, type StoredUsage, upsertUsage } from "../domain/stored-usage.js"; +import type { TranscriptPosition } from "../domain/transcript-position.js"; +import type { UsageRecord } from "../domain/usage-record.js"; +import type { DirectoryOutcome, DirectoryResolver, ResolutionRun } from "./directory-resolver.js"; +import type { ReadClaudeUsageUseCase } from "./read-claude-usage-use-case.js"; +import type { SnapshotBindingsUseCase } from "./snapshot-bindings-use-case.js"; + +export interface IngestResult { + /** `AIDD_TELEMETRY=0`: nothing was read, nothing was written. */ + readonly refused: boolean; + /** Transcripts that were new or had grown since the last ingest. */ + readonly filesRead: number; + readonly added: number; + readonly updated: number; + /** Shapes the reader met and did not recognise. */ + readonly unrecognised: number; + /** Lines of the ledger that were not records. */ + readonly skippedLedgerLines: number; + /** Branch declarations newly snapshotted. */ + readonly snapshots: number; + /** Billed calls, by the reason they were not stored. */ + readonly notStored: Readonly>; +} + +const NOT_STORED: Readonly> = { + "outside-repo": 0, + "never-seen-alive": 0, + "no-consent": 0, + "unreadable-consent": 0, + "no-cwd": 0, + undated: 0, +}; + +export interface IngestOptions { + /** `AIDD_TELEMETRY=0`, read by the composition root. */ + readonly refusedByEnvironment: boolean; +} + +function moved(before: TranscriptPosition | undefined, now: TranscriptPosition): boolean { + return ( + before === undefined || + before.offset !== now.offset || + before.size !== now.size || + before.identity !== now.identity + ); +} + +/** Reads what every transcript gained since the last ingest and stores the billed calls of the + * projects that opted in, one record per call. Safe to run any number of times, from any + * number of processes. */ +export class IngestUsageUseCase { + constructor( + private readonly reader: ReadClaudeUsageUseCase, + private readonly ledger: UsageLedger, + private readonly directories: DirectoryResolver, + private readonly snapshots: SnapshotBindingsUseCase, + private readonly options: IngestOptions + ) {} + + async execute(): Promise { + if (this.options.refusedByEnvironment) { + return { + refused: true, + filesRead: 0, + added: 0, + updated: 0, + unrecognised: 0, + skippedLedgerLines: 0, + snapshots: 0, + notStored: NOT_STORED, + }; + } + return this.ledger.exclusively(() => this.ingest()); + } + + private async ingest(): Promise { + const before = await this.ledger.positions(); + const reading = await this.reader.execute(before); + const filesRead = [...reading.positions].filter(([path, now]) => + moved(before.get(path), now) + ).length; + + const resolver = await this.directories.open(); + const notStored = { ...NOT_STORED }; + const toStore: StoredUsage[] = []; + const live = new Map(); + for (const record of reading.records) { + const outcome = await this.outcomeOf(record, resolver); + if ("skipped" in outcome) { + notStored[outcome.skipped] += 1; + continue; + } + toStore.push({ ...record, repository_id: outcome.stored.repository_id }); + if (outcome.live) live.set(outcome.stored.repository_id, outcome.stored.root); + } + + let added = 0; + let updated = 0; + let skippedLedgerLines = 0; + if (toStore.length > 0) { + const held = await this.ledger.load(); + skippedLedgerLines = held.skippedLines; + const merged = upsertUsage(held.records, toStore); + added = merged.added; + updated = merged.updated; + if (added + updated > 0) await this.ledger.save(merged.records); + } + await resolver.close(); + + let snapshots = 0; + for (const [repositoryId, root] of live) { + snapshots += await this.snapshots.execute(repositoryId, root); + } + + // Last, so a crash before it only means the same lines are read again. + if (filesRead > 0) await this.ledger.savePositions(reading.positions); + + return { + refused: false, + filesRead, + added, + updated, + unrecognised: reading.unrecognised.length, + skippedLedgerLines, + snapshots, + notStored, + }; + } + + /** A call with no time has no month to be kept in, and one with no directory has no + * repository to be tied to. */ + private async outcomeOf(record: UsageRecord, resolver: ResolutionRun): Promise { + if (monthOf(record.at) === null) return { skipped: "undated" }; + if (record.cwd === null) return { skipped: "no-cwd" }; + return resolver.resolve(record.cwd); + } +} diff --git a/cli/src/contexts/telemetry/application/snapshot-bindings-use-case.ts b/cli/src/contexts/telemetry/application/snapshot-bindings-use-case.ts new file mode 100644 index 000000000..345abdd6c --- /dev/null +++ b/cli/src/contexts/telemetry/application/snapshot-bindings-use-case.ts @@ -0,0 +1,31 @@ +import { needsReflog, nextSnapshot, snapshotKey } from "../domain/branch-binding.js"; +import type { BindingSnapshotStore } from "../domain/ports/binding-snapshot-store.js"; +import type { BranchBindingSource } from "../domain/ports/branch-binding-source.js"; + +/** Records what a repository's branches are declared as, right now. Attribution reads only + * these snapshots, so a branch that is deleted or renamed later keeps the work it carried. */ +export class SnapshotBindingsUseCase { + constructor( + private readonly source: BranchBindingSource, + private readonly store: BindingSnapshotStore, + private readonly now: () => Date + ) {} + + /** How many snapshots were added: none when every branch still reads as it did. */ + async execute(repositoryId: string, root: string): Promise { + const observed = await this.source.bindings(root); + if (observed.length === 0) return 0; + const latest = await this.store.latest(); + const taken = []; + for (const binding of observed) { + const previous = latest.get(snapshotKey(repositoryId, binding.branch)); + const created = needsReflog(previous, binding) + ? await this.source.createdAt(root, binding.branch) + : null; + const next = nextSnapshot(repositoryId, previous, binding, created, this.now().toISOString()); + if (next !== null) taken.push(next); + } + if (taken.length > 0) await this.store.append(taken); + return taken.length; + } +} diff --git a/cli/src/contexts/telemetry/domain/branch-binding.ts b/cli/src/contexts/telemetry/domain/branch-binding.ts new file mode 100644 index 000000000..eadd93fc7 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/branch-binding.ts @@ -0,0 +1,159 @@ +import { tryParseJson } from "../../../kernel/reading/json-file.js"; +import { asPlainObject } from "../../../kernel/reading/plain-object.js"; +import { compareText } from "./text-order.js"; + +/** What a branch's git config says about its declaration. */ +export interface BranchConfigBinding { + readonly branch: string; + readonly task: string | null; + readonly ticket: string | null; + /** As the declaration wrote it. */ + readonly declared_at: string | null; + /** Declared, and declared to have no task. */ + readonly none: boolean; +} + +/** A branch's declaration at one moment, kept so a deleted or renamed branch still attributes + * the work it carried. */ +export interface BranchSnapshot extends BranchConfigBinding { + readonly repository_id: string; + /** The oldest reflog entry when first read: the work before it was not on this branch. */ + readonly branch_created_at: string | null; + readonly snapshot_at: string; +} + +export function snapshotKey(repositoryId: string, branch: string): string { + return `${repositoryId}\u0000${branch}`; +} + +const PREFIX = "branch."; +/** git lists the variable part of a key in lower case; the branch name keeps its case. */ +const FIELDS = { + task: ".aiddtask", + ticket: ".aiddticket", + declared_at: ".aidddeclaredat", +} as const; + +interface DeclaredFields { + task: string | null; + ticket: string | null; + declared_at: string | null; +} + +function textOrNull(value: string): string | null { + return value === "" ? null : value; +} + +/** Reads `git config --local -z --get-regexp` output: entries end in NUL, a key and its value + * are split by a newline, and a key given with no value has no newline at all. A branch name + * can hold dots, so the known suffix is cut off the end, never split on. */ +export function parseBranchConfig(output: string): BranchConfigBinding[] { + const branches = new Map(); + for (const entry of output.split("\0")) { + if (!entry.startsWith(PREFIX)) continue; + const newline = entry.indexOf("\n"); + const key = newline === -1 ? entry : entry.slice(0, newline); + const value = newline === -1 ? "" : entry.slice(newline + 1); + const lower = key.toLowerCase(); + for (const [field, suffix] of Object.entries(FIELDS) as [keyof DeclaredFields, string][]) { + if (!lower.endsWith(suffix)) continue; + const branch = key.slice(PREFIX.length, key.length - suffix.length); + const held = branches.get(branch) ?? { task: null, ticket: null, declared_at: null }; + held[field] = textOrNull(value); + branches.set(branch, held); + } + } + return [...branches.entries()] + .sort(([a], [b]) => compareText(a, b)) + .map(([branch, fields]) => ({ + branch, + ...fields, + none: fields.declared_at !== null && fields.task === null, + })); +} + +/** `git reflog show --date=iso-strict --format=%gd` lists newest first, each as + * `refs/heads/@{