From 9e9b8f2daec09a231d4e413e63439788ec4a71df Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 21:13:27 +0200 Subject: [PATCH 01/10] docs(telemetry): contract for choosing who is measured at opt-in Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/contract.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md new file mode 100644 index 000000000..2736e5e86 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md @@ -0,0 +1,74 @@ +--- +status: in-progress +issue: 1029 +--- + +# Who is measured is chosen at opt-in + +## Need + +`aidd telemetry report --axis person` names a person only when they ran `aidd telemetry identity `, a command almost nobody finds (#1029). Otherwise the row reads `not set`, and nothing says under which name a machine is measured. The person axis matters once a team's figures are gathered (#662), so each person must have chosen how they appear before that. + +## Decisions (agreed with the user) + +| # | Decision | Why | +| --- | --- | --- | +| 1 | With no identity, the text report labels the person row `anonymous`. The envelope is unchanged: kind `absent`, `key: null`. | Version 1 keeps its meaning, a real identity named `anonymous` cannot be confused with none, and `--absent person` (#1030) still selects it. | +| 2 | A non-interactive `on --yes` with no identity set and neither `--as` nor `--anonymous` is an error, exit 1, before anything is written. | Same rule as `--yes` today: nothing implicit. | +| 3 | Choosing to stay anonymous is not stored. The next `on`, in any clone, asks again while no identity is set. | `on` runs once per clone; the identity store keeps its one-field format. | + +## Surface + +| Command | Behaviour | +| --- | --- | +| `aidd telemetry on` (interactive), no identity set | After the opt-in is confirmed, one question: name me as the proposal, as another identifier (then typed), or stay anonymous. With no proposal, the first choice is not offered. | +| `aidd telemetry on`, identity set | No question. The output says under which name the clone is measured. | +| `aidd telemetry on --as ` | Sets `` as the identity, replacing one already set; the output names the previous one when it changes. | +| `aidd telemetry on --anonymous` | Sets nothing. With an identity already set, it is an error, exit 1, pointing to `aidd telemetry identity --off`. | +| `aidd telemetry status` | Says under which name this machine is measured, or `anonymous` and how to be named. | +| `aidd telemetry report` | Text: the person row with no identity reads `anonymous`. JSON unchanged. | + +## Rules + +| # | Rule | +| --- | --- | +| 1 | The proposal is the git `user.name` of the directory the command runs in, else its `user.email`, as git resolves them (local, then global). Read through a port. It is shown, never stored without the person choosing it. | +| 2 | A proposal or a typed identifier goes through `personIdOf` (trimmed, one line, 1 to 128 characters). A proposal it rejects is not offered. A rejected `--as` is an error before anything is written. | +| 3 | `--as` and `--anonymous` together: error, exit 1. Either with `--yes` or interactive is accepted. | +| 4 | The identity is written only when `on` succeeds (status `on`). A refused `on` writes no identity. | +| 5 | `AIDD_TELEMETRY=0` refuses as today, before git is asked for a proposal. | +| 6 | `identity`, `identity ` and `identity --off` are unchanged. | + +```mermaid +flowchart TD + F["flags checked (rules 2, 3, decision 2)"] --> C{"opt-in confirmed?"} + C -->|no| N["nothing changed"] + C -->|yes| O["on use case"] + O -->|refused| R["refused, no identity written"] + O -->|on| I{"identity set?"} + I -->|yes, no --as| S["say: measured as X"] + I -->|no, or --as| Q["--as / --anonymous / question"] + Q --> W["write identity, or none"] +``` + +The question comes after the opt-in is confirmed and before the use case writes anything, so a refusal leaves both consent and identity untouched: the choice is passed to the use case, which writes the identity only on success. + +## Unchanged + +The identity store format, `identity.json` and where it lives; the envelope; consent; what is measured. The identity never leaves the machine. + +## Docs + +Usage contract (person axis, opt-in), `cli/aidd_docs/memory/telemetry.md`, `plugins/aidd-telemetry/README.md`, and the `00-init` skill's `start` action: it asks the person how to appear, offering the git name, and runs `aidd telemetry on --yes --as ` or `--anonymous`. + +## Acceptance + +| # | Criterion | Proof | +| --- | --- | --- | +| 1 | Every row of the Surface and Rules tables has a test, written first and seen failing | tests, red run in `evidence.md` | +| 2 | A refused `on` writes neither consent nor identity, whatever the flags | use-case test | +| 3 | No identity is ever written without `--as`, a typed id, or the proposal chosen | test per path, plus a mutation on the write guard | +| 4 | The envelope is byte-identical to before for every axis | existing envelope tests unchanged | +| 5 | Telemetry mutation at 96 or above, every mutant on a changed line killed or explained | stryker | +| 6 | `pnpm smoke`, `scripts/check-claude-tokens-end-to-end.cjs` both modes pass | `evidence.md` | +| 7 | Docs and the `00-init` skill say what `on` asks and how to answer it non-interactively | grep, plus a fresh checker | From aec957f24063246df4d141503a50a7766c1bcfc7 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 21:13:39 +0200 Subject: [PATCH 02/10] docs(telemetry): order the opt-in question before the use case writes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/contract.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md index 2736e5e86..0dae83fe4 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md @@ -41,14 +41,15 @@ issue: 1029 ```mermaid flowchart TD - F["flags checked (rules 2, 3, decision 2)"] --> C{"opt-in confirmed?"} + F["flags checked (rules 2, 3)"] --> C{"opt-in confirmed?"} C -->|no| N["nothing changed"] - C -->|yes| O["on use case"] - O -->|refused| R["refused, no identity written"] - O -->|on| I{"identity set?"} - I -->|yes, no --as| S["say: measured as X"] - I -->|no, or --as| Q["--as / --anonymous / question"] - Q --> W["write identity, or none"] + C -->|yes| I{"identity set?"} + I -->|"yes, no --as"| K["choice: keep it (--anonymous here: error)"] + I -->|"no, or --as"| Q["choice: --as, --anonymous, or the question
(--yes with neither: error, decision 2)"] + K --> O["on use case, given the choice"] + Q --> O + O -->|refused| R["refused: no consent, no identity written"] + O -->|on| W["identity written when one was chosen; output names it"] ``` The question comes after the opt-in is confirmed and before the use case writes anything, so a refusal leaves both consent and identity untouched: the choice is passed to the use case, which writes the identity only on success. From 41422f8cdd09beeb73b81289f7d9929a068aa8a3 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 21:14:01 +0200 Subject: [PATCH 03/10] docs(telemetry): plan for choosing who is measured at opt-in Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/phase-1.md | 21 ++++++++++++++ .../2026_10_15_identity-at-opt-in/phase-2.md | 18 ++++++++++++ .../2026_10_15_identity-at-opt-in/phase-3.md | 11 +++++++ .../2026_10_15_identity-at-opt-in/plan.md | 29 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-2.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-3.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/plan.md diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-1.md new file mode 100644 index 000000000..feb0d65ef --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-1.md @@ -0,0 +1,21 @@ +--- +status: todo +--- + +# Instruction: The on use case takes an identity choice and writes it only on success + +## Architecture projection + +```txt +cli/src/contexts/telemetry/ +├── domain/ports/identity/git-user-source.ts ✅ proposal: user.name else user.email in a directory, or null +├── infrastructure/…git-user-source-adapter.ts ✅ `git config user.name` / `user.email` in cwd +├── application/switch/telemetry-on-use-case.ts ✏️ execute(cwd, choice); writes identity only on `on`; result names identity before/after +└── composition wiring ✏️ +cli/tests/… ✏️ use-case + adapter integration (real temp git repo, GIT_CONFIG_NOSYSTEM=1, sandboxed HOME) +``` + +## Tasks + +1. Red first: refused `on` with `{set}` writes nothing; `on` with `{set}` writes it and reports previous; `none` with identity set is refused before any write; `keep` writes nothing; proposal rejected by `personIdOf` returns null; adapter reads local over global, name over email. Record red. +2. Code to green. Show the write guard is a guard (remove it, see the refused-writes-nothing test fail). diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-2.md new file mode 100644 index 000000000..8d548510d --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-2.md @@ -0,0 +1,18 @@ +--- +status: todo +--- + +# Instruction: The command asks, the flags answer, status and report name the person + +## Architecture projection + +```txt +cli/src/presentation/commands/telemetry.ts ✏️ on: --as, --anonymous; checks; question via Prompter.select (+ input for another id) +cli/src/presentation/display/telemetry/* ✏️ on output names the identity; status line; person absent label `anonymous` +cli/src/contexts/telemetry/application/telemetry-status-use-case.ts ✏️ the identity, if the status result lacks it +``` + +## Tasks + +1. Red first, one test per Surface row and Rules row of the contract: `--as` + `--anonymous` error; `--as` invalid error; `--yes` without choice and no identity error, nothing written; `--anonymous` with identity set error naming `identity --off`; interactive question offers proposal / another / anonymous, no proposal => two choices; identity set => no question; `AIDD_TELEMETRY=0` => no git proposal read; status names person or `anonymous` + how to be named; text report person row `anonymous`; JSON envelope unchanged. Record red. +2. Code to green, thin command per `cli/.claude/skills/command`. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-3.md new file mode 100644 index 000000000..2733c255e --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-3.md @@ -0,0 +1,11 @@ +--- +status: todo +--- + +# Instruction: Docs, skill and gates + +## Tasks + +1. `aidd_docs/product/usage-contract.md` (person, opt-in), `cli/aidd_docs/memory/telemetry.md`, `plugins/aidd-telemetry/README.md`. +2. `plugins/aidd-telemetry/skills/00-init/actions/start.md`: explain and ask how the person appears (offer the git name, another id, or anonymous), then run `aidd telemetry on --yes --as ` or `--anonymous`; tests table updated. +3. Gates: `cd cli && pnpm test`; `pnpm test:mutation:telemetry` >= 96 with changed-line survivors explained; `pnpm smoke`; build + `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` and `--attended` (update the script for the new `on` flags if it calls `on --yes`); bundle budget; lefthook pre-commit and pre-push. Outputs in `evidence.md`. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/plan.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/plan.md new file mode 100644 index 000000000..d4de9e7ad --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/plan.md @@ -0,0 +1,29 @@ +--- +objective: "Each person chooses, at opt-in, the name they are measured under, or to stay anonymous, and the report and status say which." +status: in-progress +--- + +# Plan: Who is measured is chosen at opt-in + +## Overview + +| Field | Value | +| ---------- | ----------------------- | +| **Goal** | `on` asks or takes `--as`/`--anonymous`; `status` and the text report name the person or `anonymous` | +| **Source** | [`contract.md`](./contract.md), issue #1029 | + +## Phases + +| # | Phase | File | +| --- | ------------ | ---------------------------- | +| 1 | The on use case takes an identity choice and writes it only on success | [`phase-1.md`](./phase-1.md) | +| 2 | The command asks, the flags answer, status and report name the person | [`phase-2.md`](./phase-2.md) | +| 3 | Docs, skill and gates | [`phase-3.md`](./phase-3.md) | + +## Decisions + +| Decision | Why | +| ---------- | ----- | +| The git name is read through a new port, not the `RepositoryLocator` | the locator answers what a directory is; who the person is is another question, asked only by `on` | +| The use case receives the choice (`keep`, `none`, `{ set: id }`) and writes after consent | a refused `on` must write nothing (rule 4) | +| `anonymous` is a text label only | decision 1 of the contract | From ba5b91d01e29e35668bc63c5cf46308cbe3b4a89 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 21:21:51 +0200 Subject: [PATCH 04/10] feat(telemetry): on writes the identity chosen, and only once it is on The on use case takes who the clone is measured as: the identity already set, none, or one chosen. It refuses an identifier the identity rules refuse, and staying anonymous while an identity is set, before writing anything, and writes the identity only after consent is granted. A proposal is read from git's user.name, else user.email, never under AIDD_TELEMETRY=0. The command still passes keep: nothing changes yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/evidence.md | 71 ++++++++ .../2026_10_15_identity-at-opt-in/phase-1.md | 2 +- .../identity/propose-identity-use-case.ts | 16 ++ .../switch/telemetry-on-use-case.ts | 25 ++- .../domain/ports/identity/git-user-source.ts | 5 + .../identity/git-user-source-adapter.ts | 16 ++ cli/src/presentation/commands/telemetry.ts | 2 +- .../telemetry/telemetry-lifecycle-display.ts | 3 + cli/src/runtime/wiring/telemetry.ts | 10 +- .../orchestrator-deps.arch.test.ts | 5 +- .../propose-identity-use-case.unit.test.ts | 39 +++++ .../telemetry-switch-use-cases.unit.test.ts | 158 ++++++++++++++---- .../consent-scenarios.integration.test.ts | 2 +- ...it-user-source-adapter.integration.test.ts | 62 +++++++ .../telemetry-lifecycle.integration.test.ts | 54 +++--- .../telemetry-wiring.integration.test.ts | 2 +- .../telemetry-lifecycle-display.unit.test.ts | 16 ++ 17 files changed, 416 insertions(+), 72 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md create mode 100644 cli/src/contexts/telemetry/application/identity/propose-identity-use-case.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/identity/git-user-source.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/identity/git-user-source-adapter.ts create mode 100644 cli/tests/contexts/telemetry/application/identity/propose-identity-use-case.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/identity/git-user-source-adapter.integration.test.ts diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md new file mode 100644 index 000000000..9afbce186 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md @@ -0,0 +1,71 @@ +# Evidence + +## Red runs + +### Phase 1 + +`pnpm exec vitest run tests/contexts/telemetry/application/switch/telemetry-switch-use-cases.unit.test.ts tests/contexts/telemetry/application/identity/propose-identity-use-case.unit.test.ts tests/contexts/telemetry/infrastructure/identity/git-user-source-adapter.integration.test.ts`, before the choice, the proposal and the git user source exist: + +```txt +× writes the identity chosen once the clone is on, and names it + AssertionError: expected null to be 'Ada Example' +× replaces the identity already set, and names the previous one + AssertionError: expected 'Ada at Home' to be 'Ada Example' +× refuses an identifier the identity rules refuse, before writing anything + AssertionError: expected { status: 'on', …(5) } to deeply equal { status: 'refused', …(1) } +× refuses to stay anonymous while an identity is set, before writing anything + AssertionError: expected { status: 'on', …(5) } to deeply equal { status: 'refused', …(1) } +× stays anonymous when nobody is named, and writes no identity +× keeps the identity as it is, and names it + AssertionError: expected { status: 'on', …(5) } to match object { identity: { …(2) } } +FAIL propose-identity-use-case.unit.test.ts + Error: Cannot find module '…/application/identity/propose-identity-use-case.js' +FAIL git-user-source-adapter.integration.test.ts + Error: Cannot find module '…/infrastructure/identity/git-user-source-adapter.js' +Tests 6 failed | 44 passed (50) +``` + +Green before the change, by construction, so not a red run: "writes no identity when the clone is refused, whatever was chosen" and "writes no identity when the consent log cannot be trusted" (today's use case writes no identity at all). Both are proven guards by the mutation below. + +`pnpm exec vitest run tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts`, before the two refusals have words: + +```txt +× refuses an identity it cannot keep, and says nothing changed + AssertionError: expected undefined to be 'An identity is one line of at most 12…' +× sends a person who wants to stay anonymous to removing the identity first + AssertionError: the given combination of arguments (undefined and string) is invalid for this assertion +Tests 2 failed | 19 passed (21) +``` + +#### Guards + +Each mutation reverted after its run. + +The identity written before the clone is checked (the write moved above `readCloneConsent`): + +```txt +× writes no identity when the clone is refused, whatever was chosen +× writes no identity when the consent log cannot be trusted +Tests 2 failed | 48 passed (50) +``` + +`none` never refused while an identity is set (`previous === "never"`): + +```txt +× refuses to stay anonymous while an identity is set, before writing anything +Tests 1 failed | 49 passed (50) +``` + +The proposal no longer short-circuited under `AIDD_TELEMETRY=0`: + +```txt +× is nothing under AIDD_TELEMETRY=0, and git is not asked +Tests 1 failed | 7 passed (8) +``` + +The git user source asking the email first: + +```txt +× is the name, before the email +Tests 1 failed | 12 passed (13) +``` diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-1.md index feb0d65ef..7b64c8bbc 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-1.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-1.md @@ -1,5 +1,5 @@ --- -status: todo +status: done --- # Instruction: The on use case takes an identity choice and writes it only on success diff --git a/cli/src/contexts/telemetry/application/identity/propose-identity-use-case.ts b/cli/src/contexts/telemetry/application/identity/propose-identity-use-case.ts new file mode 100644 index 000000000..4cda0ffea --- /dev/null +++ b/cli/src/contexts/telemetry/application/identity/propose-identity-use-case.ts @@ -0,0 +1,16 @@ +import { personIdOf } from "../../domain/identity/person-identity.js"; +import type { GitUserSource } from "../../domain/ports/identity/git-user-source.js"; + +/** The identifier offered to a person who has none yet, or `null` when there is none to offer. */ +export class ProposeIdentityUseCase { + constructor( + private readonly git: GitUserSource, + private readonly refusedByEnvironment: boolean + ) {} + + async execute(cwd: string): Promise { + if (this.refusedByEnvironment) return null; + const user = await this.git.userIn(cwd); + return user === null ? null : personIdOf(user); + } +} diff --git a/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts b/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts index 4729d5def..4a3135205 100644 --- a/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts +++ b/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts @@ -1,6 +1,8 @@ import type { CloneIdentity } from "../../domain/consent/clone-identity.js"; +import { personIdOf } from "../../domain/identity/person-identity.js"; import type { ConsentHistory } from "../../domain/ports/consent-history.js"; import type { ConsentSource } from "../../domain/ports/consent-source.js"; +import type { PersonIdentityStore } from "../../domain/ports/identity/person-identity-store.js"; import type { LocatedDirectory, RepositoryLocator } from "../../domain/ports/repository-locator.js"; import type { ResolutionStore } from "../../domain/ports/resolution-store.js"; import type { ClaudeSettingsSource } from "../../domain/ports/switch/claude-settings-source.js"; @@ -27,6 +29,9 @@ type Located = Extract; export type LegacyConfigOutcome = "none" | "block-removed" | "file-deleted" | "unparseable"; +/** Who the clone is measured as: the identity already set, none, or the one the person chose. */ +export type IdentityChoice = "keep" | "none" | { readonly set: string }; + export type OnResult = | { readonly status: "refused"; @@ -37,7 +42,9 @@ export type OnResult = | "outside-repository" | "unreadable-git-config" | "unidentified-clone" - | "damaged-consent-log"; + | "damaged-consent-log" + | "invalid-identity" + | "identity-set"; } | { readonly status: "on"; @@ -51,6 +58,8 @@ export type OnResult = /** How long Claude Code keeps a transcript, and whether that is short of what history * needs. */ readonly retention: { readonly days: number; readonly short: boolean }; + /** `null` for nobody: the person stays anonymous. */ + readonly identity: { readonly personId: string | null; readonly previous: string | null }; }; /** Opts a clone in, in its own git config, and leaves it as if the previous measurement had @@ -69,10 +78,17 @@ export class TelemetryOnUseCase { private readonly claudeSettings: ClaudeSettingsSource, private readonly environment: ResolutionEnvironment, /** A fresh random token for each interval. */ - private readonly newToken: () => string + private readonly newToken: () => string, + private readonly identities: PersonIdentityStore ) {} - async execute(cwd: string): Promise { + async execute(cwd: string, choice: IdentityChoice): Promise { + const previous = await this.identities.read(); + const chosen = typeof choice === "object" ? personIdOf(choice.set) : null; + if (typeof choice === "object" && chosen === null) + return { status: "refused", reason: "invalid-identity" }; + if (choice === "none" && previous !== null) + return { status: "refused", reason: "identity-set" }; const clone = await readCloneConsent(this.locator, this.consents, cwd); if (clone.status === "refused") return clone; const { located } = clone; @@ -80,6 +96,8 @@ export class TelemetryOnUseCase { if (identity === null) return { status: "refused", reason: "unidentified-clone" }; const consentWritten = await this.ledger.exclusively(() => this.grant(located.root, identity)); if (consentWritten === "damaged") return { status: "refused", reason: "damaged-consent-log" }; + // Written only once the clone is on: a refused `on` leaves the identity as it was. + if (chosen !== null) await this.identities.write(chosen); const legacyConfig = await this.clearLegacyConfig(located.root); // Pairing and removal live in one adapter call: the line and its script go together. @@ -95,6 +113,7 @@ export class TelemetryOnUseCase { hook, journal, retention: { days, short: retentionShort(days) }, + identity: { personId: choice === "keep" ? previous : chosen, previous }, }; } diff --git a/cli/src/contexts/telemetry/domain/ports/identity/git-user-source.ts b/cli/src/contexts/telemetry/domain/ports/identity/git-user-source.ts new file mode 100644 index 000000000..7db0c34d9 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/identity/git-user-source.ts @@ -0,0 +1,5 @@ +/** Who git says the person is: only ever offered to them, never stored by itself. */ +export interface GitUserSource { + /** `user.name` as git resolves it in `cwd`, else `user.email`, or `null` when neither is set. */ + userIn(cwd: string): Promise; +} diff --git a/cli/src/contexts/telemetry/infrastructure/identity/git-user-source-adapter.ts b/cli/src/contexts/telemetry/infrastructure/identity/git-user-source-adapter.ts new file mode 100644 index 000000000..67628dcf6 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/identity/git-user-source-adapter.ts @@ -0,0 +1,16 @@ +import type { GitUserSource } from "../../domain/ports/identity/git-user-source.js"; +import { runGit } from "../run-git.js"; + +export class GitUserSourceAdapter implements GitUserSource { + /** `env` carries none of git's own variables, which would point it at another repository. */ + constructor(private readonly env: NodeJS.ProcessEnv) {} + + async userIn(cwd: string): Promise { + for (const key of ["user.name", "user.email"]) { + // A key not set, or a config git cannot read, prints nothing: `on` refuses the latter. + const value = runGit(this.env, cwd, ["config", "--get", key]).stdout.trim(); + if (value !== "") return value; + } + return null; + } +} diff --git a/cli/src/presentation/commands/telemetry.ts b/cli/src/presentation/commands/telemetry.ts index d20b97f5b..8e7b309a0 100644 --- a/cli/src/presentation/commands/telemetry.ts +++ b/cli/src/presentation/commands/telemetry.ts @@ -56,7 +56,7 @@ export function registerTelemetryCommand(program: Command): void { output.info("Nothing changed."); return; } - const result = await deps.telemetry.telemetryOnUseCase.execute(projectRoot); + const result = await deps.telemetry.telemetryOnUseCase.execute(projectRoot, "keep"); printOnResult(output, result); if (result.status === "refused") process.exit(1); } catch (error) { diff --git a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts index 0d35c2de3..58b6e48dd 100644 --- a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts +++ b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts @@ -19,6 +19,9 @@ const REFUSALS = { "ledger/consents.jsonl holds a line that is not a consent event, so the log cannot be trusted and nothing was changed. Run `aidd telemetry forget --yes`, then `aidd telemetry on` again: nothing from before is stored.", "unidentified-clone": "This file system gives the clone's git directory no identity, so nothing measured here could be told to be its own. Nothing was changed.", + "invalid-identity": "An identity is one line of at most 128 characters. Nothing was changed.", + "identity-set": + "An identity is set, so this clone cannot be measured anonymously. Remove it with `aidd telemetry identity --off`, then run this again. Nothing was changed.", } as const; function plural(count: number, noun: string): string { diff --git a/cli/src/runtime/wiring/telemetry.ts b/cli/src/runtime/wiring/telemetry.ts index 02ab6b5ee..8361ec7b0 100644 --- a/cli/src/runtime/wiring/telemetry.ts +++ b/cli/src/runtime/wiring/telemetry.ts @@ -7,6 +7,7 @@ import { DeclareTaskUseCase } from "../../contexts/telemetry/application/declare import { DirectoryResolver } from "../../contexts/telemetry/application/directory-resolver.js"; import { ForgetTelemetryUseCase } from "../../contexts/telemetry/application/forget/forget-telemetry-use-case.js"; import { ManageIdentityUseCase } from "../../contexts/telemetry/application/identity/manage-identity-use-case.js"; +import { ProposeIdentityUseCase } from "../../contexts/telemetry/application/identity/propose-identity-use-case.js"; import { IngestUsageUseCase } from "../../contexts/telemetry/application/ingest-usage-use-case.js"; import { ReadUsageUseCase } from "../../contexts/telemetry/application/read-usage-use-case.js"; import { DeclaredBindings } from "../../contexts/telemetry/application/report/declared-bindings.js"; @@ -33,6 +34,7 @@ import { RepositoryDeclarationsAdapter } from "../../contexts/telemetry/infrastr import { GitBranchBindingSourceAdapter } from "../../contexts/telemetry/infrastructure/git-branch-binding-source-adapter.js"; import { GitConsentAdapter } from "../../contexts/telemetry/infrastructure/git-consent-adapter.js"; import { GitRepositoryLocatorAdapter } from "../../contexts/telemetry/infrastructure/git-repository-locator-adapter.js"; +import { GitUserSourceAdapter } from "../../contexts/telemetry/infrastructure/identity/git-user-source-adapter.js"; import { PersonIdentityAdapter } from "../../contexts/telemetry/infrastructure/identity/person-identity-adapter.js"; import { ResolutionStoreAdapter } from "../../contexts/telemetry/infrastructure/resolution-store-adapter.js"; import { ClaudeSettingsAdapter } from "../../contexts/telemetry/infrastructure/switch/claude-settings-adapter.js"; @@ -51,6 +53,7 @@ export interface TelemetryDeps { snapshotBindingsUseCase: SnapshotBindingsUseCase; reportUsageUseCase: ReportUsageUseCase; manageIdentityUseCase: ManageIdentityUseCase; + proposeIdentityUseCase: ProposeIdentityUseCase; telemetryOnUseCase: TelemetryOnUseCase; telemetryOffUseCase: TelemetryOffUseCase; forgetTelemetryUseCase: ForgetTelemetryUseCase; @@ -154,6 +157,10 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { identity ), manageIdentityUseCase: new ManageIdentityUseCase(identity), + proposeIdentityUseCase: new ProposeIdentityUseCase( + new GitUserSourceAdapter(gitEnv), + refusedByEnvironment + ), telemetryOnUseCase: new TelemetryOnUseCase( locator, consents, @@ -166,7 +173,8 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { consentHistory, new ClaudeSettingsAdapter(claudeDir), environment, - randomUUID + randomUUID, + identity ), telemetryOffUseCase: new TelemetryOffUseCase( locator, diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index ae467fc4c..ff3bf83cb 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -115,8 +115,9 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] // The consent is written through its own port, apart from the project config it clears. // The consent's history is the eleventh: the interval opens with the key, under the same lock. // The token that names the interval, in the key and in the history, is the twelfth: a fresh - // random one per interval, injected so a test can name it. - { path: "src/contexts/telemetry/application/switch/telemetry-on-use-case.ts", injected: 12 }, + // random one per interval, injected so a test can name it. The person's identity is the + // thirteenth: written only once the consent is, so a refused `on` leaves it as it was. + { path: "src/contexts/telemetry/application/switch/telemetry-on-use-case.ts", injected: 13 }, // Opting out is the consent's write, then the end of the clone's consent interval, under the // ledger's lock so an ingest sees the key and the interval change together. The order is only // this class's to hold; the clock is the sixth. diff --git a/cli/tests/contexts/telemetry/application/identity/propose-identity-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/identity/propose-identity-use-case.unit.test.ts new file mode 100644 index 000000000..4cb6b3f68 --- /dev/null +++ b/cli/tests/contexts/telemetry/application/identity/propose-identity-use-case.unit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { ProposeIdentityUseCase } from "../../../../../src/contexts/telemetry/application/identity/propose-identity-use-case.js"; + +function setup(user: string | null, refusedByEnvironment = false) { + const asked: string[] = []; + const use = new ProposeIdentityUseCase( + { + async userIn(cwd) { + asked.push(cwd); + return user; + }, + }, + refusedByEnvironment + ); + return { asked, use }; +} + +describe("the identifier offered to a person who has none", () => { + it("is what git says in the directory, trimmed", async () => { + const s = setup(" Ada Example "); + expect(await s.use.execute("/w/repo")).toBe("Ada Example"); + expect(s.asked).toEqual(["/w/repo"]); + }); + + it("is nothing when git knows nobody", async () => { + expect(await setup(null).use.execute("/w/repo")).toBeNull(); + }); + + it("is nothing when the identity would be refused", async () => { + expect(await setup("a".repeat(129)).use.execute("/w/repo")).toBeNull(); + expect(await setup("Ada\nExample").use.execute("/w/repo")).toBeNull(); + }); + + it("is nothing under AIDD_TELEMETRY=0, and git is not asked", async () => { + const s = setup("Ada Example", true); + expect(await s.use.execute("/w/repo")).toBeNull(); + expect(s.asked).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/switch/telemetry-switch-use-cases.unit.test.ts b/cli/tests/contexts/telemetry/application/switch/telemetry-switch-use-cases.unit.test.ts index f77eb0820..59f57aeeb 100644 --- a/cli/tests/contexts/telemetry/application/switch/telemetry-switch-use-cases.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/switch/telemetry-switch-use-cases.unit.test.ts @@ -14,6 +14,7 @@ import { FakeConsents, FakeLocator, InMemoryConsentHistory, + InMemoryIdentity, InMemoryLedger, InMemoryResolutions, } from "../../../../helpers/ports/in-memory-telemetry.js"; @@ -62,6 +63,7 @@ function setup(settings: (string | null)[] = []) { }; const hooksSeen: string[] = []; const journalSeen: string[] = []; + const identities = new InMemoryIdentity(); const writer = { async set(root: string, value: string) { events.push("consent"); @@ -109,7 +111,8 @@ function setup(settings: (string | null)[] = []) { history, { texts: async () => settings }, { caseInsensitiveFileSystem: false, now: () => clock.now }, - () => `tok-${++minted}` + () => `tok-${++minted}`, + identities ); const off = new TelemetryOffUseCase(locator, consents, writer, ledger, history, () => clock.now); locator.directories.set("/w/repo", repository("/w/repo", "/w/main")); @@ -127,13 +130,14 @@ function setup(settings: (string | null)[] = []) { events, hooksSeen, journalSeen, + identities, }; } describe("aidd telemetry on", () => { it("writes consent before it cleans, and cleans the repository it was run in", async () => { const s = setup(); - const result = await s.on.execute("/w/repo"); + const result = await s.on.execute("/w/repo", "keep"); expect(s.events.slice(0, 5)).toEqual(["lock", "consent", "unlock", "hooks", "journal"]); expect(s.writes).toEqual([{ root: "/w/repo", value: "2:tok-1" }]); expect(s.hooksSeen).toEqual(["/w/repo"]); @@ -148,9 +152,9 @@ describe("aidd telemetry on", () => { it("still cleans, and writes no consent, when the clone already granted this version", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); s.writes.length = 0; - const result = await s.on.execute("/w/repo"); + const result = await s.on.execute("/w/repo", "keep"); expect(s.writes).toEqual([]); expect(s.hooksSeen).toEqual(["/w/repo", "/w/repo"]); expect(result).toMatchObject({ status: "on", consentWritten: false }); @@ -160,16 +164,16 @@ describe("aidd telemetry on", () => { for (const value of ["off", "1", "true", "2", "2:forged"]) { const s = setup(); s.consents.values.set("/w/repo", value); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.writes).toEqual([{ root: "/w/repo", value: "2:tok-1" }]); } }); it("mints a fresh token each time it opens an interval", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); await s.off.execute("/w/repo"); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.writes.map((write) => write.value)).toEqual(["2:tok-1", "off", "2:tok-2"]); }); @@ -180,7 +184,7 @@ describe("aidd telemetry on", () => { s.events.push(`interval-${event.kind}`); await append(event); }; - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.events.slice(0, 4)).toEqual(["lock", "consent", "interval-open", "unlock"]); }); @@ -190,7 +194,7 @@ describe("aidd telemetry on", () => { "/w/repo", '{\n "keep": 1,\n "telemetry": { "enabled": true, "endpoint": "x" }\n}\n' ); - const result = await s.on.execute("/w/repo"); + const result = await s.on.execute("/w/repo", "keep"); expect(result).toMatchObject({ legacyConfig: "block-removed" }); expect(s.config.written).toEqual([{ root: "/w/repo", text: '{\n "keep": 1\n}\n' }]); expect(s.config.removed).toEqual([]); @@ -199,7 +203,7 @@ describe("aidd telemetry on", () => { it("deletes .aidd/config.json when the block was all it held", async () => { const s = setup(); s.config.texts.set("/w/repo", '{"telemetry":{"enabled":true,"endpoint":"x"}}'); - expect(await s.on.execute("/w/repo")).toMatchObject({ legacyConfig: "file-deleted" }); + expect(await s.on.execute("/w/repo", "keep")).toMatchObject({ legacyConfig: "file-deleted" }); expect(s.config.removed).toEqual(["/w/repo"]); expect(s.config.written).toEqual([]); }); @@ -207,7 +211,7 @@ describe("aidd telemetry on", () => { it("leaves a config that does not parse alone, and still turns measurement on", async () => { const s = setup(); s.config.texts.set("/w/repo", "{nope"); - expect(await s.on.execute("/w/repo")).toMatchObject({ + expect(await s.on.execute("/w/repo", "keep")).toMatchObject({ consentWritten: true, legacyConfig: "unparseable", }); @@ -218,14 +222,14 @@ describe("aidd telemetry on", () => { it("never reads .aidd/config.json as consent", async () => { const s = setup(); s.config.texts.set("/w/repo", '{"telemetry":{"enabled":true,"version":2}}'); - const result = await s.on.execute("/w/repo"); + const result = await s.on.execute("/w/repo", "keep"); expect(result).toMatchObject({ consentWritten: true }); expect(s.writes).toEqual([{ root: "/w/repo", value: "2:tok-1" }]); }); it("refuses outside a repository without touching anything", async () => { const s = setup(); - expect(await s.on.execute("/elsewhere")).toEqual({ + expect(await s.on.execute("/elsewhere", "keep")).toEqual({ status: "refused", reason: "outside-repository", }); @@ -235,7 +239,7 @@ describe("aidd telemetry on", () => { it("refuses a repository git cannot read, as an unreadable git config, without touching anything", async () => { const s = setup(); s.locator.directories.set("/w/broken", { status: "unreadable" }); - expect(await s.on.execute("/w/broken")).toEqual({ + expect(await s.on.execute("/w/broken", "keep")).toEqual({ status: "refused", reason: "unreadable-git-config", }); @@ -250,7 +254,7 @@ describe("aidd telemetry on", () => { it("refuses a git config it cannot read without touching anything", async () => { const s = setup(); s.consents.unreadable.add("/w/repo"); - expect(await s.on.execute("/w/repo")).toEqual({ + expect(await s.on.execute("/w/repo", "keep")).toEqual({ status: "refused", reason: "unreadable-git-config", }); @@ -260,14 +264,14 @@ describe("aidd telemetry on", () => { it("reads nothing back: the offsets are left as they are, and the root is remembered under the lock", async () => { const s = setup(); s.ledger.stored.set("/t", { offset: 9, size: 9, identity: "i" }); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.ledger.stored.size).toBe(1); expect(s.events.slice(5)).toEqual(["lock", "unlock"]); }); it("remembers its own root, seen alive, so forget can find the clone later", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.resolutions.resolutions.get(resolutionKey("/w/repo", MAIN))).toEqual({ dir: "/w/repo", repository_id: ID, @@ -291,9 +295,9 @@ describe("aidd telemetry on", () => { it("opens the clone's interval at its own time, once", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); s.clock.now = new Date(T2); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.history.written).toEqual([{ kind: "open", token: "tok-1", clone: MAIN, at: T1 }]); }); @@ -301,7 +305,7 @@ describe("aidd telemetry on", () => { for (const value of ["2", "2:forged"]) { const s = setup(); s.consents.values.set("/w/repo", value); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.history.written).toEqual([{ kind: "open", token: "tok-1", clone: MAIN, at: T1 }]); expect(s.writes).toEqual([{ root: "/w/repo", value: "2:tok-1" }]); } @@ -312,7 +316,7 @@ describe("aidd telemetry on", () => { s.history.written.push({ kind: "open", token: "old", clone: MAIN, at: T1 }); s.consents.values.set("/w/repo", "off"); s.clock.now = new Date(T2); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.history.written).toEqual([ { kind: "open", token: "old", clone: MAIN, at: T1 }, { kind: "close", token: "old", at: T2 }, @@ -324,20 +328,20 @@ describe("aidd telemetry on", () => { const s = setup(); const other = cloneOf("/w/other/.git"); s.history.written.push({ kind: "open", token: "theirs", clone: other, at: T1 }); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.history.written.map((event) => event.kind)).toEqual(["open", "open"]); }); it("opens the consent under the ledger's lock, with the key", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.events.slice(0, 3)).toEqual(["lock", "consent", "unlock"]); }); it("refuses a clone the file system gives no identity, and changes nothing", async () => { const s = setup(); s.locator.directories.set("/w/repo", { ...repository("/w/repo", "/w/main"), clone: null }); - expect(await s.on.execute("/w/repo")).toEqual({ + expect(await s.on.execute("/w/repo", "keep")).toEqual({ status: "refused", reason: "unidentified-clone", }); @@ -351,7 +355,7 @@ describe("aidd telemetry on", () => { "/w/repo", repository("/w/repo", "/w/main", { remote: null, rootCommit: null }) ); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); expect(s.resolutions.saves).toBe(0); }); @@ -362,7 +366,7 @@ describe("aidd telemetry on", () => { at = T3 ) { s.resolutions.resolutions.set(resolutionKey(entry.dir, entry.clone), entry); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); const run = await new DirectoryResolver(s.locator, s.consents, s.resolutions, s.history, { caseInsensitiveFileSystem: false, now: () => s.clock.now, @@ -416,8 +420,8 @@ describe("aidd telemetry on", () => { it("does not rewrite what it already remembered of its own root", async () => { const s = setup(); - await s.on.execute("/w/repo"); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); + await s.on.execute("/w/repo", "keep"); expect(s.resolutions.saves).toBe(1); }); @@ -427,7 +431,91 @@ describe("aidd telemetry on", () => { [['{"cleanupPeriodDays":3649}', '{"cleanupPeriodDays":3650}'], 3649, true], ])("reports retention from %j", async (settings, days, short) => { const s = setup(settings as (string | null)[]); - expect(await s.on.execute("/w/repo")).toMatchObject({ retention: { days, short } }); + expect(await s.on.execute("/w/repo", "keep")).toMatchObject({ retention: { days, short } }); + }); +}); + +describe("aidd telemetry on, given who the clone is measured as", () => { + it("writes the identity chosen once the clone is on, and names it", async () => { + const s = setup(); + const result = await s.on.execute("/w/repo", { set: " Ada Example " }); + expect(s.identities.personId).toBe("Ada Example"); + expect(result).toMatchObject({ + status: "on", + identity: { personId: "Ada Example", previous: null }, + }); + }); + + it("replaces the identity already set, and names the previous one", async () => { + const s = setup(); + s.identities.personId = "Ada at Home"; + const result = await s.on.execute("/w/repo", { set: "Ada Example" }); + expect(s.identities.personId).toBe("Ada Example"); + expect(result).toMatchObject({ + identity: { personId: "Ada Example", previous: "Ada at Home" }, + }); + }); + + it("writes no identity when the clone is refused, whatever was chosen", async () => { + const s = setup(); + s.locator.directories.set("/w/repo", { ...repository("/w/repo", "/w/main"), clone: null }); + for (const cwd of ["/elsewhere", "/w/repo"]) { + expect(await s.on.execute(cwd, { set: "Ada Example" })).toMatchObject({ + status: "refused", + }); + } + expect(s.identities.writes).toBe(0); + expect(s.writes).toEqual([]); + }); + + it("writes no identity when the consent log cannot be trusted", async () => { + const s = setup(); + s.history.damaged = true; + expect(await s.on.execute("/w/repo", { set: "Ada Example" })).toEqual({ + status: "refused", + reason: "damaged-consent-log", + }); + expect(s.identities.writes).toBe(0); + }); + + it("refuses an identifier the identity rules refuse, before writing anything", async () => { + for (const typed of [" ", "Ada\nExample", "a".repeat(129)]) { + const s = setup(); + expect(await s.on.execute("/w/repo", { set: typed })).toEqual({ + status: "refused", + reason: "invalid-identity", + }); + expect(s.events).toEqual([]); + expect(s.identities.writes).toBe(0); + } + }); + + it("refuses to stay anonymous while an identity is set, before writing anything", async () => { + const s = setup(); + s.identities.personId = "Ada Example"; + expect(await s.on.execute("/w/repo", "none")).toEqual({ + status: "refused", + reason: "identity-set", + }); + expect(s.events).toEqual([]); + expect(s.identities.personId).toBe("Ada Example"); + }); + + it("stays anonymous when nobody is named, and writes no identity", async () => { + const s = setup(); + const result = await s.on.execute("/w/repo", "none"); + expect(s.identities.writes).toBe(0); + expect(result).toMatchObject({ status: "on", identity: { personId: null, previous: null } }); + }); + + it("keeps the identity as it is, and names it", async () => { + const s = setup(); + s.identities.personId = "Ada Example"; + const result = await s.on.execute("/w/repo", "keep"); + expect(s.identities.writes).toBe(0); + expect(result).toMatchObject({ + identity: { personId: "Ada Example", previous: "Ada Example" }, + }); }); }); @@ -442,7 +530,7 @@ describe("aidd telemetry off, kept as the end of an interval", () => { it("closes the consent at its own time, and the clone's calls after it are refused", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); s.clock.now = new Date(T2); await s.off.execute("/w/repo"); expect(s.history.written).toEqual([ @@ -458,7 +546,7 @@ describe("aidd telemetry off, kept as the end of an interval", () => { it("closes a consent whose key was already changed by hand", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); s.consents.values.set("/w/repo", "off"); s.clock.now = new Date(T2); expect(await s.off.execute("/w/repo")).toEqual({ status: "off", changed: true }); @@ -467,7 +555,7 @@ describe("aidd telemetry off, kept as the end of an interval", () => { it("closes the interval its key names at its path, though the identity read there moved since `on`", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); // Where Linux refuses `statx`, the birth time is the change time, and `on` itself moved it. s.locator.directories.set("/w/repo", { ...repository("/w/repo", "/w/main"), @@ -480,7 +568,7 @@ describe("aidd telemetry off, kept as the end of an interval", () => { it("leaves the interval of the clone a copy was made from, its key naming it at another path", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); s.locator.directories.set("/w/copy", repository("/w/copy")); s.consents.values.set("/w/copy", s.writes[0]?.value ?? ""); await s.off.execute("/w/copy"); @@ -489,7 +577,7 @@ describe("aidd telemetry off, kept as the end of an interval", () => { it("writes under the ledger's lock, the interval with the key", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); s.events.length = 0; await s.off.execute("/w/repo"); expect(s.events).toEqual(["lock", "consent", "unlock"]); @@ -503,7 +591,7 @@ describe("aidd telemetry off, kept as the end of an interval", () => { it("closes a consent once", async () => { const s = setup(); - await s.on.execute("/w/repo"); + await s.on.execute("/w/repo", "keep"); await s.off.execute("/w/repo"); await s.off.execute("/w/repo"); expect(s.history.written.map((event) => event.kind)).toEqual(["open", "close"]); diff --git a/cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts b/cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts index 451fbd6cb..e59c50ce9 100644 --- a/cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts +++ b/cli/tests/contexts/telemetry/consent-scenarios.integration.test.ts @@ -143,7 +143,7 @@ class World { async on(dir: string, seconds: number) { this.at(seconds); - return deps.telemetryOnUseCase.execute(dir); + return deps.telemetryOnUseCase.execute(dir, "keep"); } async off(dir: string, seconds: number) { diff --git a/cli/tests/contexts/telemetry/infrastructure/identity/git-user-source-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/identity/git-user-source-adapter.integration.test.ts new file mode 100644 index 000000000..930404a04 --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/identity/git-user-source-adapter.integration.test.ts @@ -0,0 +1,62 @@ +import { 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 { GitUserSourceAdapter } from "../../../../../src/contexts/telemetry/infrastructure/identity/git-user-source-adapter.js"; +import { git, initRepository, sandboxGitEnv } from "../../../../helpers/git-sandbox.js"; + +let root: string; +let repo: string; +let global: string; +let env: NodeJS.ProcessEnv; +let source: GitUserSourceAdapter; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-git-user-")); + global = join(root, "global.gitconfig"); + await writeFile(global, ""); + env = sandboxGitEnv(root, { GIT_CONFIG_GLOBAL: global }); + repo = join(root, "repo"); + initRepository(repo, env); + source = new GitUserSourceAdapter(env); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +const set = (scope: "--local" | "--global", key: string, value: string) => + git(repo, env, "config", scope, key, value); + +describe("who git says the person is, in a directory", () => { + it("is nobody when neither a name nor an email is set", async () => { + expect(await source.userIn(repo)).toBeNull(); + }); + + it("is the name, before the email", async () => { + set("--global", "user.email", "ada@example.com"); + set("--global", "user.name", "Ada Example"); + expect(await source.userIn(repo)).toBe("Ada Example"); + }); + + it("is the email when no name is set", async () => { + set("--global", "user.email", "ada@example.com"); + expect(await source.userIn(repo)).toBe("ada@example.com"); + }); + + it("is the email when the name is set empty", async () => { + set("--global", "user.name", ""); + set("--global", "user.email", "ada@example.com"); + expect(await source.userIn(repo)).toBe("ada@example.com"); + }); + + it("is the clone's own name over the global one", async () => { + set("--global", "user.name", "Ada Example"); + set("--local", "user.name", "Ada at Work"); + expect(await source.userIn(repo)).toBe("Ada at Work"); + }); + + it("is the global name outside any repository", async () => { + set("--global", "user.name", "Ada Example"); + expect(await source.userIn(root)).toBe("Ada Example"); + }); +}); diff --git a/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts b/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts index 45968c934..980820dd5 100644 --- a/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts +++ b/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts @@ -94,7 +94,7 @@ describe("opting in to a repository the previous version measured", () => { await configure(BARE); expect(commit("before")).toContain(`AIDD-Session-Id: ${SESSION}`); - const result = await deps.telemetryOnUseCase.execute(repo); + const result = await deps.telemetryOnUseCase.execute(repo, "keep"); expect(result).toMatchObject({ status: "on", @@ -110,7 +110,7 @@ describe("opting in to a repository the previous version measured", () => { install(foreign); await configure(BARE); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); const hook = join(hooksDir(), "prepare-commit-msg"); expect(read(hook)).toBe('#!/bin/sh\n# mine\r\n echo "a" \necho end'); @@ -134,7 +134,7 @@ describe("opting in to a repository the previous version measured", () => { writeFileSync(join(repo, "lefthook.yml"), lefthook); await configure(BARE); - const result = await deps.telemetryOnUseCase.execute(repo); + const result = await deps.telemetryOnUseCase.execute(repo, "keep"); expect(result).toMatchObject({ hook: { lineRemoved: true, delegateRemoved: false, stillCalledBy: ["lefthook.yml"] }, @@ -151,7 +151,7 @@ describe("opting in to a repository the previous version measured", () => { writeFileSync(join(repo, ".husky", "prepare-commit-msg"), husky); await configure(BARE); - const result = await deps.telemetryOnUseCase.execute(repo); + const result = await deps.telemetryOnUseCase.execute(repo, "keep"); expect(result).toMatchObject({ hook: { delegateRemoved: false, stillCalledBy: [".husky/prepare-commit-msg"] }, @@ -164,7 +164,7 @@ describe("opting in to a repository the previous version measured", () => { mkdirSync(hooksDir(), { recursive: true }); writeFileSync(join(hooksDir(), DELEGATE_FILE), "#!/bin/sh\necho mine\n"); await configure(BARE); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); expect(existsSync(join(hooksDir(), DELEGATE_FILE))).toBe(true); }); @@ -174,7 +174,7 @@ describe("opting in to a repository the previous version measured", () => { writeFileSync(join(repo, ".gitignore"), "dist/\naidd_docs/runs/\nnode_modules/\n"); await configure(BARE); - const result = await deps.telemetryOnUseCase.execute(repo); + const result = await deps.telemetryOnUseCase.execute(repo, "keep"); expect(result).toMatchObject({ journal: { journalRemoved: true, ignoreEntryRemoved: true } }); expect(existsSync(join(repo, "aidd_docs", "runs"))).toBe(false); @@ -187,7 +187,7 @@ describe("opting in to a repository the previous version measured", () => { git(repo, box.gitEnv, "add", "-f", "aidd_docs/runs/a.jsonl"); await configure(BARE); - const result = await deps.telemetryOnUseCase.execute(repo); + const result = await deps.telemetryOnUseCase.execute(repo, "keep"); expect(result).toMatchObject({ journal: { journalRemoved: false, trackedKept: true } }); expect(existsSync(join(repo, "aidd_docs", "runs", "a.jsonl"))).toBe(true); @@ -204,7 +204,7 @@ describe("opting in to a repository the previous version measured", () => { it("sets the consent in the repository's git config, not in .aidd/config.json", async () => { await configure(JSON.stringify({ keep: { me: 1 } })); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); expect(consentOf()).toMatch(TOKEN_KEY); expect(read(join(repo, ".aidd", "config.json"))).toBe('{"keep":{"me":1}}'); const [opened, ...rest] = intervalLines(); @@ -214,10 +214,10 @@ describe("opting in to a repository the previous version measured", () => { }); it("mints a fresh token for each interval and ends the last one at off", async () => { - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); const first = consentOf(); await deps.telemetryOffUseCase.execute(repo); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); expect(consentOf()).toMatch(TOKEN_KEY); expect(consentOf()).not.toBe(first); expect(intervalLines().map((line) => Object.keys(line).join(","))).toEqual([ @@ -231,14 +231,14 @@ describe("opting in to a repository the previous version measured", () => { await configure( '{\n "keep": { "me": 1 },\n "telemetry": { "enabled": true, "endpoint": "x" }\n}\n' ); - const result = await deps.telemetryOnUseCase.execute(repo); + const result = await deps.telemetryOnUseCase.execute(repo, "keep"); expect(result).toMatchObject({ legacyConfig: "block-removed" }); expect(read(join(repo, ".aidd", "config.json"))).toBe('{\n "keep": { "me": 1 }\n}\n'); }); it("deletes .aidd/config.json when the block was all it held", async () => { await configure('{"telemetry":{"enabled":true,"endpoint":"x"}}'); - expect(await deps.telemetryOnUseCase.execute(repo)).toMatchObject({ + expect(await deps.telemetryOnUseCase.execute(repo, "keep")).toMatchObject({ legacyConfig: "file-deleted", }); expect(existsSync(join(repo, ".aidd", "config.json"))).toBe(false); @@ -256,7 +256,7 @@ describe("opting in to a repository the previous version measured", () => { it("turns off with the git config value off, and leaves .aidd/config.json alone", async () => { await configure('{"keep":1}'); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); expect(await deps.telemetryOffUseCase.execute(repo)).toEqual({ status: "off", changed: true }); expect(consentOf()).toBe("off"); expect(read(join(repo, ".aidd", "config.json"))).toBe('{"keep":1}'); @@ -267,7 +267,7 @@ describe("opting in to a repository the previous version measured", () => { git(repo, box.gitEnv, "worktree", "add", "-q", "-b", "feat/y", linked); writeTranscript(linked, ["a"], "s-1", soon()); expect(await deps.ingestUsageUseCase.execute()).toMatchObject({ added: 0 }); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); writeTranscript(linked, ["b"], "s-2", soon()); expect(await deps.ingestUsageUseCase.execute()).toMatchObject({ added: 1 }); }); @@ -275,7 +275,7 @@ describe("opting in to a repository the previous version measured", () => { it("leaves a config that does not parse as it is, and still turns measurement on", async () => { await configure("{nope"); install("#!/bin/sh\n\n"); - expect(await deps.telemetryOnUseCase.execute(repo)).toMatchObject({ + expect(await deps.telemetryOnUseCase.execute(repo, "keep")).toMatchObject({ status: "on", legacyConfig: "unparseable", }); @@ -287,7 +287,7 @@ describe("opting in to a repository the previous version measured", () => { it("refuses outside a repository", async () => { const outside = join(box.root, "not-a-repo"); mkdirSync(outside); - expect(await deps.telemetryOnUseCase.execute(outside)).toEqual({ + expect(await deps.telemetryOnUseCase.execute(outside, "keep")).toEqual({ status: "refused", reason: "outside-repository", }); @@ -345,7 +345,7 @@ describe("measurement starts at on", () => { it("does not read back what was said before opting in, though ingest had not read past it", async () => { await configure(BARE); writeTranscript(repo, ["a", "b"]); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); // Written days before `on`: not even read. expect(await deps.ingestUsageUseCase.execute()).toMatchObject({ added: 0, filesRead: 0 }); @@ -357,7 +357,7 @@ describe("measurement starts at on", () => { writeTranscript(repo, ["a", "b"]); expect(await deps.ingestUsageUseCase.execute()).toMatchObject({ added: 0 }); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); writeTranscript(repo, ["c"], "s-2", soon()); expect(await deps.ingestUsageUseCase.execute()).toMatchObject({ @@ -371,7 +371,7 @@ describe("measurement starts at on", () => { it("does not store a directory that is gone, seen alive before the clone opted in", async () => { await configure(BARE); // Another clone measured, so this one's transcripts are read before it opts in. - await deps.telemetryOnUseCase.execute(box.repository("gadgets")); + await deps.telemetryOnUseCase.execute(box.repository("gadgets"), "keep"); const sub = join(repo, "sub"); mkdirSync(sub); writeTranscript(sub, ["a"], "s-1", soon()); @@ -380,7 +380,7 @@ describe("measurement starts at on", () => { expect(remembered.directories.some((d: { dir: string }) => d.dir.endsWith("sub"))).toBe(true); rmSync(sub, { recursive: true }); - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); writeTranscript(sub, ["b"], "s-2", soon()); expect(await deps.ingestUsageUseCase.execute()).toMatchObject({ added: 1 }); @@ -404,7 +404,7 @@ describe("how long Claude Code keeps what is measured", () => { const stat = [statSync(settings).mtimeMs, statSync(project).mtimeMs]; const names = claudeFiles(); - const result = await deps.telemetryOnUseCase.execute(repo); + const result = await deps.telemetryOnUseCase.execute(repo, "keep"); expect(result).toMatchObject({ retention: { days: 30, short: true } }); expect(read(settings)).toBe('{"theme":"dark"}'); @@ -416,12 +416,12 @@ describe("how long Claude Code keeps what is measured", () => { it("is satisfied by 3650 in the user's settings, and overridden by a project's shorter one", async () => { await configure(BARE); writeFileSync(join(box.claude, "settings.json"), '{"cleanupPeriodDays":3650}'); - expect(await deps.telemetryOnUseCase.execute(repo)).toMatchObject({ + expect(await deps.telemetryOnUseCase.execute(repo, "keep")).toMatchObject({ retention: { days: 3650, short: false }, }); mkdirSync(join(repo, ".claude")); writeFileSync(join(repo, ".claude", "settings.local.json"), '{"cleanupPeriodDays":7}'); - expect(await deps.telemetryOnUseCase.execute(repo)).toMatchObject({ + expect(await deps.telemetryOnUseCase.execute(repo, "keep")).toMatchObject({ retention: { days: 7, short: true }, }); }); @@ -465,7 +465,7 @@ function snapshot(): Record { describe("forgetting", () => { async function measured(): Promise { - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); declare("main", "checkout"); writeTranscript(repo, ["a"], "s-1", soon()); await deps.ingestUsageUseCase.execute(); @@ -517,9 +517,9 @@ describe("forgetting", () => { }); it("unsets the consent of a clone where `on` ran and no session ever did, and of one that ran off", async () => { - await deps.telemetryOnUseCase.execute(repo); + await deps.telemetryOnUseCase.execute(repo, "keep"); const other = box.repository("gadgets"); - await deps.telemetryOnUseCase.execute(other); + await deps.telemetryOnUseCase.execute(other, "keep"); await deps.telemetryOffUseCase.execute(other); expect(git(other, box.gitEnv, "config", "--local", "--get", "aidd.telemetry").trim()).toBe( "off" @@ -548,7 +548,7 @@ describe("forgetting", () => { await measured(); const other = box.repository("gadgets"); git(other, box.gitEnv, "config", "--local", "branch.main.aiddTask", "x"); - await deps.telemetryOnUseCase.execute(other); + await deps.telemetryOnUseCase.execute(other, "keep"); writeTranscript(other, ["z"], "s-2"); await deps.ingestUsageUseCase.execute(); rmSync(other, { recursive: true }); diff --git a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts index 21767357a..f1fa308bd 100644 --- a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts @@ -290,7 +290,7 @@ describe("aidd telemetry on", () => { it("turns measurement on in the directory it was run from, without asking, with --yes", async () => { turnOn.mockResolvedValue(ON); expect(await run("on", "--yes")).toBeNull(); - expect(turnOn).toHaveBeenCalledWith(process.cwd()); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), "keep"); expect(confirm).not.toHaveBeenCalled(); expect(stdout()).toContain("Measurement is on"); }); diff --git a/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts b/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts index 6c48918b1..f43c9944d 100644 --- a/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts @@ -15,6 +15,7 @@ const ON: Extract = { hook: { lineRemoved: false, delegateRemoved: false, stillCalledBy: [] }, journal: { journalRemoved: false, trackedKept: false, ignoreEntryRemoved: false }, retention: { days: 3650, short: false }, + identity: { personId: null, previous: null }, }; function on(overrides: Partial): CapturingOutput { @@ -113,6 +114,21 @@ describe("printOnResult", () => { expect(output.at("error")[1]).toContain("This clone's git config"); }); + it("refuses an identity it cannot keep, and says nothing changed", () => { + const output = new CapturingOutput(false); + printOnResult(output, { status: "refused", reason: "invalid-identity" }); + expect(output.at("error")[0]).toBe( + "An identity is one line of at most 128 characters. Nothing was changed." + ); + }); + + it("sends a person who wants to stay anonymous to removing the identity first", () => { + const output = new CapturingOutput(false); + printOnResult(output, { status: "refused", reason: "identity-set" }); + expect(output.at("error")[0]).toContain("`aidd telemetry identity --off`"); + expect(output.at("error")[0]).toContain("Nothing was changed."); + }); + it("sends a damaged consent log to forget, never to a hand edit", () => { const output = new CapturingOutput(false); printOnResult(output, { status: "refused", reason: "damaged-consent-log" }); From 83b7cdb6da431f1be96d3069d262989e90ff5346 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 21:31:48 +0200 Subject: [PATCH 05/10] feat(telemetry): on asks who is measured, and status names them `aidd telemetry on` takes --as or --anonymous. Interactively, with no identity set, it asks once the opt-in is confirmed: the git name when there is one, another identifier, or anonymous. --yes with no identity and neither flag is an error before anything is written, so scripts and tests now pass --anonymous. `on` and `status` say who the measurement names, and the text report labels nobody `anonymous`; the envelope is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/evidence.md | 95 +++++++++++++++ .../2026_10_15_identity-at-opt-in/phase-2.md | 2 +- cli/scripts/smoke-tools.sh | 2 +- .../switch/telemetry-on-use-case.ts | 9 +- .../application/telemetry-status-use-case.ts | 7 +- cli/src/presentation/commands/telemetry.ts | 58 ++++++++- .../presentation/display/telemetry-display.ts | 13 +- .../telemetry/telemetry-lifecycle-display.ts | 14 ++- .../telemetry/telemetry-report-display.ts | 2 +- cli/src/runtime/wiring/telemetry.ts | 5 +- .../context-boundary.arch.test.ts | 4 +- .../orchestrator-deps.arch.test.ts | 3 + .../telemetry-status-use-case.unit.test.ts | 14 ++- .../telemetry-lifecycle.integration.test.ts | 9 ++ cli/tests/e2e/telemetry-ingest.e2e.test.ts | 2 +- cli/tests/e2e/telemetry-journey.e2e.test.ts | 2 +- cli/tests/e2e/telemetry-lifecycle.e2e.test.ts | 63 +++++++++- cli/tests/e2e/telemetry-report.e2e.test.ts | 6 +- cli/tests/e2e/telemetry-task.e2e.test.ts | 2 +- cli/tests/golden/snapshots/help/surface.json | 2 +- .../telemetry-wiring.integration.test.ts | 113 ++++++++++++++++-- .../display/telemetry-display.unit.test.ts | 19 ++- .../telemetry-lifecycle-display.unit.test.ts | 21 +++- .../telemetry-report-display.unit.test.ts | 2 +- scripts/check-claude-tokens-end-to-end.cjs | 2 +- 25 files changed, 428 insertions(+), 43 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md index 9afbce186..b5215ec50 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md @@ -69,3 +69,98 @@ The git user source asking the email first: × is the name, before the email Tests 1 failed | 12 passed (13) ``` + +### Phase 2 + +`pnpm exec vitest run tests/presentation tests/contexts/telemetry/application/telemetry-status-use-case.unit.test.ts tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts`, before the command asks and the displays name the person: + +```txt +× turns measurement on in the directory it was run from, without asking, with --yes and a choice + CommanderError: error: unknown option '--anonymous' +× names the person given with --as + CommanderError: error: unknown option '--as' +× refuses --yes with no identity set and no choice, before anything is written + AssertionError: expected null to be 1 +× offers the proposal, another identifier, or anonymity, and names the one chosen + TypeError: Cannot read properties of undefined (reading 'map') +× offers no proposal when there is none / takes another identifier, typed / stays anonymous when chosen +× asks nothing about the person when a flag answered / exits 1 when it is refused +× refuses --as with --anonymous, before asking anything +× refuses an identifier the identity rules refuse, before asking anything +× says the person axis shows anonymous when nobody is named + AssertionError: expected 'No identity is set: the person axis s…' to contain 'shows "anonymous"' +× says last who the measurement names, whatever the work is bound to + AssertionError: expected [ …(2) ] to have a length of 3 but got 2 +× says who the clone is measured as, before the next step + AssertionError: expected 'That setting is shared by this clone\…' to be 'Measured anonymously: the person axis…' +× says what an absent value is on each axis (person: "anonymous") +× names the person the measurement names, or nobody + AssertionError: expected { status: 'shown', …(5) } to match object { status: 'shown', personId: null } +Tests 17 failed | 630 passed (647) +``` + +`pnpm exec vitest run --project=e2e tests/e2e/telemetry-lifecycle.e2e.test.ts tests/e2e/telemetry-report.e2e.test.ts`, the built binary before the change (every caller now passes `--anonymous`, which it does not know): + +```txt +× refuses --yes with no identity set and no choice, and writes nothing + AssertionError: expected +0 to be 1 +× names the person given with --as, in on and in status, and keeps them on the next on +× refuses to stay anonymous while an identity is set, pointing to identity --off + AssertionError: expected 'error: unknown option \'--anonymous\'…' to contain 'aidd telemetry identity --off' +× stays anonymous with --anonymous, stores no choice, and says how to be named +× turns on under AIDD_TELEMETRY=0 as before, when the choice is given +× shows the person axis as anonymous until an identity is chosen +… and every test whose setup runs `on --yes --anonymous` +Tests 23 failed | 3 passed (26) +``` + +Green before the change, so not a red run: "the identifier offered at on > is not read from git under AIDD_TELEMETRY=0" (the wiring landed in phase 1); proven a guard below. + +#### Guards + +Each mutation reverted after its run, against `tests/presentation/commands/telemetry-wiring.integration.test.ts` unless named. + +`--yes` with no identity and no choice no longer refused (`if (options.yes) return null;` removed): + +```txt +× refuses --yes with no identity set and no choice, before anything is written +Tests 1 failed | 44 passed (45) +``` + +`--as` not checked against the identity rules before asking: + +```txt +× refuses an identifier the identity rules refuse, before asking anything +Tests 1 failed | 44 passed (45) +``` + +`--as` with `--anonymous` let through: + +```txt +× refuses --as with --anonymous, before asking anything +Tests 1 failed | 44 passed (45) +``` + +An identity already set not kept (`=== "never"`): + +```txt +× keeps the identity already set when --yes is given no choice, and asks nothing +× asks nothing about the person when an identity is set +Tests 2 failed | 43 passed (45) +``` + +The composition root giving the proposal `false` instead of the environment's refusal, against `tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts`: + +```txt +× is not read from git under AIDD_TELEMETRY=0 +Tests 1 failed | 29 passed (30) +``` + +#### Green + +`cd cli && pnpm test`, after phase 2: + +```txt +Test Files 505 passed | 1 skipped (506) + Tests 6553 passed | 1 skipped (6554) +``` diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-2.md index 8d548510d..00b8c9b3e 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-2.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-2.md @@ -1,5 +1,5 @@ --- -status: todo +status: done --- # Instruction: The command asks, the flags answer, status and report name the person diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index d0a7d5f8b..63afd9111 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -212,7 +212,7 @@ unset AIDD_TELEMETRY CLAUDE_CODE_SESSION_ID P_TEL=$(new_project) (cd "$P_TEL" && git -c user.name=t -c user.email=t@t -c commit.gpgsign=false commit -q --allow-empty -m first \ && git switch -q -c feat/smoke) -run "telemetry on" 0 "Measurement is on" "$P_TEL" -- telemetry on --yes +run "telemetry on" 0 "Measurement is on" "$P_TEL" -- telemetry on --yes --anonymous run "telemetry task" 0 "Declared task" "$P_TEL" -- telemetry task smoke-task --ticket SMOKE-1 run "telemetry status" 0 "measured since" "$P_TEL" -- telemetry status run "telemetry ingest" 0 "" "$P_TEL" -- telemetry ingest diff --git a/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts b/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts index 4a3135205..7e3cbf3a5 100644 --- a/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts +++ b/cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts @@ -32,6 +32,12 @@ export type LegacyConfigOutcome = "none" | "block-removed" | "file-deleted" | "u /** Who the clone is measured as: the identity already set, none, or the one the person chose. */ export type IdentityChoice = "keep" | "none" | { readonly set: string }; +/** Who the clone is measured as, `null` for nobody, and who it was before this `on`. */ +export interface OnIdentity { + readonly personId: string | null; + readonly previous: string | null; +} + export type OnResult = | { readonly status: "refused"; @@ -58,8 +64,7 @@ export type OnResult = /** How long Claude Code keeps a transcript, and whether that is short of what history * needs. */ readonly retention: { readonly days: number; readonly short: boolean }; - /** `null` for nobody: the person stays anonymous. */ - readonly identity: { readonly personId: string | null; readonly previous: string | null }; + readonly identity: OnIdentity; }; /** Opts a clone in, in its own git config, and leaves it as if the previous measurement had diff --git a/cli/src/contexts/telemetry/application/telemetry-status-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-status-use-case.ts index e3adde8d0..0fbfb1135 100644 --- a/cli/src/contexts/telemetry/application/telemetry-status-use-case.ts +++ b/cli/src/contexts/telemetry/application/telemetry-status-use-case.ts @@ -1,6 +1,7 @@ import { type Binding, resolveBinding } from "../domain/declaration/binding-resolution.js"; import type { BranchRole } from "../domain/declaration/branch-role.js"; import type { SessionBindingStore } from "../domain/ports/bindings/session-binding-store.js"; +import type { PersonIdentityStore } from "../domain/ports/identity/person-identity-store.js"; import type { BranchDeclarations } from "./branch-declarations.js"; import type { ConsentedRepositories, RefusalReason } from "./consented-repositories.js"; @@ -14,6 +15,8 @@ export type StatusResult = readonly branch: string | null; readonly role: BranchRole; readonly binding: Binding; + /** Who the measurement names, `null` for nobody. */ + readonly personId: string | null; }; export interface StatusOptions { @@ -28,7 +31,8 @@ export class TelemetryStatusUseCase { private readonly repositories: ConsentedRepositories, private readonly sessions: SessionBindingStore, private readonly branches: BranchDeclarations, - private readonly options: StatusOptions + private readonly options: StatusOptions, + private readonly identities: PersonIdentityStore ) {} async execute(cwd: string): Promise { @@ -50,6 +54,7 @@ export class TelemetryStatusUseCase { branch: current.name, role: current.role, binding, + personId: await this.identities.read(), }; } } diff --git a/cli/src/presentation/commands/telemetry.ts b/cli/src/presentation/commands/telemetry.ts index 8e7b309a0..ca9cfb80f 100644 --- a/cli/src/presentation/commands/telemetry.ts +++ b/cli/src/presentation/commands/telemetry.ts @@ -3,17 +3,21 @@ import { REFUSED_ENVELOPE, reportEnvelopeOf, } from "../../contexts/telemetry/application/report/report-envelope.js"; +import type { IdentityChoice } from "../../contexts/telemetry/application/switch/telemetry-on-use-case.js"; import { DECLARED_BY, type DeclaredBy, requestOf, } from "../../contexts/telemetry/domain/declaration/task-declaration.js"; +import { personIdOf } from "../../contexts/telemetry/domain/identity/person-identity.js"; import { periodOf } from "../../contexts/telemetry/domain/report/period.js"; import { REPORT_AXES, type ReportAxis, } from "../../contexts/telemetry/domain/report/usage-report.js"; +import type { Prompter } from "../../kernel/ports/prompter.js"; import { createDeps } from "../../runtime/wiring/framework.js"; +import type { TelemetryDeps } from "../../runtime/wiring/telemetry.js"; import { printForgetResult, printOffResult, @@ -29,6 +33,39 @@ import { import { ErrorHandler } from "../error-handler.js"; import { parseGlobalOptions } from "./global-options.js"; +interface OnOptions { + readonly yes: boolean; + readonly as?: string; + readonly anonymous: boolean; +} + +/** Who the clone is measured as: a flag's answer, the identity already set, or the person's + * answer. `null` when none of them can be had. */ +async function identityChoice( + telemetry: TelemetryDeps, + prompter: Prompter, + cwd: string, + options: OnOptions +): Promise { + if (options.as !== undefined) return { set: options.as }; + if (options.anonymous) return "none"; + if ((await telemetry.manageIdentityUseCase.show()).status === "set") return "keep"; + if (options.yes) return null; + const proposal = await telemetry.proposeIdentityUseCase.execute(cwd); + const answer = await prompter.select( + "How should your own measurement name you? The identity stays on this machine.", + [ + ...(proposal === null + ? [] + : [{ name: `As "${proposal}", your git name`, value: { set: proposal } }]), + { name: "As another identifier, typed next", value: "another" }, + { name: "Anonymously", value: "none" }, + ] + ); + if (answer !== "another") return answer; + return { set: await prompter.input("Identifier (one line, at most 128 characters):") }; +} + export function registerTelemetryCommand(program: Command): void { const telemetry = program .command("telemetry") @@ -38,8 +75,18 @@ export function registerTelemetryCommand(program: Command): void { .command("on") .description("Measure this clone, and remove what the previous version left in it") .option("--yes", "Do not ask first", false) - .action(async (cmdOptions: { yes: boolean }) => { + .option("--as ", "Name you by this identifier on your own measurement") + .option("--anonymous", "Name nobody on your own measurement", false) + .action(async (cmdOptions: OnOptions) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); + if (cmdOptions.as !== undefined && cmdOptions.anonymous) { + output.error("--as names you and --anonymous names nobody: give one of them."); + process.exit(1); + } + if (cmdOptions.as !== undefined && personIdOf(cmdOptions.as) === null) { + output.error("An identity is one line of at most 128 characters. Nothing was changed."); + process.exit(1); + } if (!cmdOptions.yes && !process.stdout.isTTY) { output.error("Nothing to ask in a non-interactive run: pass --yes to turn measurement on."); process.exit(1); @@ -56,7 +103,14 @@ export function registerTelemetryCommand(program: Command): void { output.info("Nothing changed."); return; } - const result = await deps.telemetry.telemetryOnUseCase.execute(projectRoot, "keep"); + const choice = await identityChoice(deps.telemetry, deps.prompter, projectRoot, cmdOptions); + if (choice === null) { + output.error( + "No identity is set, and --yes asks nothing: pass --as to be named, or --anonymous to name nobody." + ); + process.exit(1); + } + const result = await deps.telemetry.telemetryOnUseCase.execute(projectRoot, choice); printOnResult(output, result); if (result.status === "refused") process.exit(1); } catch (error) { diff --git a/cli/src/presentation/display/telemetry-display.ts b/cli/src/presentation/display/telemetry-display.ts index 8c28f489e..738669cad 100644 --- a/cli/src/presentation/display/telemetry-display.ts +++ b/cli/src/presentation/display/telemetry-display.ts @@ -121,6 +121,15 @@ export function printTelemetryStatus(output: CLIOutput, result: StatusResult): v return; } output.info(`This clone is measured since ${result.since.slice(0, 10)}.`); + printBinding(output, result); + output.info( + result.personId === null + ? "Measured anonymously. To be named, run `aidd telemetry identity `." + : `Measured as "${result.personId}".` + ); +} + +function printBinding(output: CLIOutput, result: Extract): void { const { binding, sessionId, branch, role } = result; if (binding.state === "unbound") { const where = @@ -151,11 +160,11 @@ export function printIdentityResult(output: CLIOutput, result: IdentityResult): output.success(`Measurement names you as "${result.personId}". Stop with \`--off\`.`); return; case "removed": - output.success('Identity removed: the person axis shows "not set".'); + output.success('Identity removed: the person axis shows "anonymous".'); return; case "unset": output.info( - 'No identity is set: the person axis shows "not set". Choose one with `aidd telemetry identity `.' + 'No identity is set: the person axis shows "anonymous". Choose one with `aidd telemetry identity `.' ); return; case "refused": diff --git a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts index 58b6e48dd..4f79e264f 100644 --- a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts +++ b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts @@ -3,7 +3,10 @@ import type { ForgetResult, } from "../../../contexts/telemetry/application/forget/forget-telemetry-use-case.js"; import type { OffResult } from "../../../contexts/telemetry/application/switch/telemetry-off-use-case.js"; -import type { OnResult } from "../../../contexts/telemetry/application/switch/telemetry-on-use-case.js"; +import type { + OnIdentity, + OnResult, +} from "../../../contexts/telemetry/application/switch/telemetry-on-use-case.js"; import type { ErasureKind } from "../../../contexts/telemetry/domain/ports/forget/measurement-erasure.js"; import { RETENTION_DEFAULT_DAYS, @@ -28,6 +31,14 @@ function plural(count: number, noun: string): string { return `${count} ${noun}${count === 1 ? "" : "s"}`; } +function identityLine({ personId, previous }: OnIdentity): string { + if (personId === null) { + return 'Measured anonymously: the person axis shows "anonymous". To be named, run `aidd telemetry identity `.'; + } + const before = previous === null || previous === personId ? "" : `, no longer as "${previous}"`; + return `Measured as "${personId}"${before}.`; +} + export function printOnResult(output: CLIOutput, result: OnResult): void { if (result.status === "refused") { output.error(REFUSALS[result.reason]); @@ -79,6 +90,7 @@ export function printOnResult(output: CLIOutput, result: OnResult): void { `To keep them longer, add \`"cleanupPeriodDays": ${RETENTION_WANTED_DAYS}\` to your Claude settings.json.` ); } + output.info(identityLine(result.identity)); output.info( "Next: declare what you work on with `aidd telemetry task [--ticket ]`, or `--none` for no task. " + "In Claude Code, the aidd-telemetry plugin's hooks ask for it before the first prompt on an undeclared working branch." diff --git a/cli/src/presentation/display/telemetry/telemetry-report-display.ts b/cli/src/presentation/display/telemetry/telemetry-report-display.ts index d4d37049b..4f3788728 100644 --- a/cli/src/presentation/display/telemetry/telemetry-report-display.ts +++ b/cli/src/presentation/display/telemetry/telemetry-report-display.ts @@ -24,7 +24,7 @@ const COLUMNS = ["Input", "Output", "Cache read", "Cache write", "Total"] as con const ABSENT: Readonly> = { total: "all", - person: "not set", + person: "anonymous", session: "no session", model: "unknown model", agent: "no agent", diff --git a/cli/src/runtime/wiring/telemetry.ts b/cli/src/runtime/wiring/telemetry.ts index 8361ec7b0..6bbd6ce37 100644 --- a/cli/src/runtime/wiring/telemetry.ts +++ b/cli/src/runtime/wiring/telemetry.ts @@ -123,11 +123,13 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { bindingsLock, declaration ); + const identity = new PersonIdentityAdapter(root, storage); const telemetryStatusUseCase = new TelemetryStatusUseCase( repositories, sessions, branchDeclarations, - declaration + declaration, + identity ); const ingestUsageUseCase = new IngestUsageUseCase( [ @@ -143,7 +145,6 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { snapshotBindingsUseCase, { refusedByEnvironment } ); - const identity = new PersonIdentityAdapter(root, storage); const claudeDir = claudeConfigDir(process.env.CLAUDE_CONFIG_DIR, homedir()); return { declareTaskUseCase, diff --git a/cli/tests/architecture/context-boundary.arch.test.ts b/cli/tests/architecture/context-boundary.arch.test.ts index a2c3d30ab..562874a43 100644 --- a/cli/tests/architecture/context-boundary.arch.test.ts +++ b/cli/tests/architecture/context-boundary.arch.test.ts @@ -135,7 +135,9 @@ const PUBLIC_MODULES: Readonly> = { "src/contexts/telemetry/application/report/report-envelope.ts", "src/contexts/telemetry/domain/report/period.ts", "src/contexts/telemetry/domain/report/usage-report.ts", - // who, if anyone, the measurement names, and the result the command prints + // who, if anyone, the measurement names, the rule an identifier the command is given must + // meet before anything is asked, and the result the command prints + "src/contexts/telemetry/domain/identity/person-identity.ts", "src/contexts/telemetry/application/identity/manage-identity-use-case.ts", // opting in, out and forgetting: the use cases a command drives and the results it prints, // the retention the display words, and what a forget lists diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index ff3bf83cb..402ebbf1c 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -122,6 +122,9 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] // ledger's lock so an ingest sees the key and the interval change together. The order is only // this class's to hold; the clock is the sixth. { path: "src/contexts/telemetry/application/switch/telemetry-off-use-case.ts", injected: 6 }, + // Status shows what the work is bound to, from the session's declarations and the branch's, + // then who the measurement names: the person's identity is the fifth, read and only shown. + { path: "src/contexts/telemetry/application/telemetry-status-use-case.ts", injected: 5 }, // Forgetting reads what was declared where (snapshots, remembered roots, the clones that // consented), clears it, and erases the files, all under the ledger's lock. The declarations // go before the files that lead to them, an order only this class holds. diff --git a/cli/tests/contexts/telemetry/application/telemetry-status-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-status-use-case.unit.test.ts index 80bc4937a..8237e47e8 100644 --- a/cli/tests/contexts/telemetry/application/telemetry-status-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/telemetry-status-use-case.unit.test.ts @@ -13,6 +13,7 @@ import { grantedBy, InMemoryBindingsLock, InMemoryConsentHistory, + InMemoryIdentity, InMemorySessions, InMemorySnapshots, } from "../../../helpers/ports/in-memory-telemetry.js"; @@ -37,6 +38,7 @@ function setup(sessionId: string | null = "sess-1234567890", refusedByEnvironmen const sessions = new InMemorySessions(); const branches = new FakeBranchStore(); const source = new FakeBindings(); + const identities = new InMemoryIdentity(); const useCase = new TelemetryStatusUseCase( new ConsentedRepositories(locator, consents, history), sessions, @@ -50,9 +52,10 @@ function setup(sessionId: string | null = "sess-1234567890", refusedByEnvironmen () => new Date() ) ), - { refusedByEnvironment, sessionId, now: () => new Date("2026-10-09T12:00:00.000Z") } + { refusedByEnvironment, sessionId, now: () => new Date("2026-10-09T12:00:00.000Z") }, + identities ); - return { useCase, consents, sessions, branches, source }; + return { useCase, consents, sessions, branches, source, identities }; } const branchBinding = { @@ -64,6 +67,13 @@ const branchBinding = { }; describe("showing what the work is bound to", () => { + it("names the person the measurement names, or nobody", async () => { + const { useCase, identities } = setup(); + expect(await useCase.execute(CWD)).toMatchObject({ status: "shown", personId: null }); + identities.personId = "Ada Example"; + expect(await useCase.execute(CWD)).toMatchObject({ personId: "Ada Example" }); + }); + it("says since when the clone is measured: the start of the interval its key names", async () => { const { useCase } = setup(); expect(await useCase.execute(CWD)).toMatchObject({ diff --git a/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts b/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts index 980820dd5..46f4b4b8e 100644 --- a/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts +++ b/cli/tests/contexts/telemetry/telemetry-lifecycle.integration.test.ts @@ -341,6 +341,15 @@ const stored = () => { .flatMap((name) => read(join(dir, name)).split("\n").filter(Boolean)).length; }; +describe("the identifier offered at on", () => { + it("is not read from git under AIDD_TELEMETRY=0", async () => { + git(repo, box.gitEnv, "config", "--local", "user.name", "Ada Example"); + vi.stubEnv("AIDD_TELEMETRY", "0"); + deps = wireTelemetry(() => box.home); + expect(await deps.proposeIdentityUseCase.execute(repo)).toBeNull(); + }); +}); + describe("measurement starts at on", () => { it("does not read back what was said before opting in, though ingest had not read past it", async () => { await configure(BARE); diff --git a/cli/tests/e2e/telemetry-ingest.e2e.test.ts b/cli/tests/e2e/telemetry-ingest.e2e.test.ts index 5f2450c54..c5e06c4ba 100644 --- a/cli/tests/e2e/telemetry-ingest.e2e.test.ts +++ b/cli/tests/e2e/telemetry-ingest.e2e.test.ts @@ -25,7 +25,7 @@ beforeEach(async () => { initRepository(repoB, gitEnv, { remote: "git@github.com:acme/other.git" }); await mkdir(join(repoA, "src")); // Opting in is `on`: a key set by hand opens no consent interval, and nothing is stored. - const on = await runCli(["telemetry", "on", "--yes"], repoA, env.fakeHome, { + const on = await runCli(["telemetry", "on", "--yes", "--anonymous"], repoA, env.fakeHome, { env: { AIDD_TELEMETRY_DIR: telemetry, CLAUDE_CONFIG_DIR: claude, AIDD_TELEMETRY: "" }, }); expect(on.exitCode, on.stderr + on.stdout).toBe(0); diff --git a/cli/tests/e2e/telemetry-journey.e2e.test.ts b/cli/tests/e2e/telemetry-journey.e2e.test.ts index 4fed8f3c8..c30e90411 100644 --- a/cli/tests/e2e/telemetry-journey.e2e.test.ts +++ b/cli/tests/e2e/telemetry-journey.e2e.test.ts @@ -213,7 +213,7 @@ const sessionStart = (session: Session, source: string) => runHook("session-start.cjs", session, { hook_event_name: "SessionStart", source }); async function optIn(): Promise { - const run = await runCli(["telemetry", "on", "--yes"], repo, env.fakeHome, { + const run = await runCli(["telemetry", "on", "--yes", "--anonymous"], repo, env.fakeHome, { env: { ...sandboxVariables(), CLAUDE_CODE_SESSION_ID: "" }, }); expect(run.exitCode, run.stderr).toBe(0); diff --git a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts index 998bfc0ab..97ef7d2cc 100644 --- a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts +++ b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts @@ -74,7 +74,7 @@ describe("aidd telemetry on", () => { leaveV1(); expect(commit()).toContain(`AIDD-Session-Id: ${SESSION}`); - const run = await cli(["on", "--yes"]); + const run = await cli(["on", "--yes", "--anonymous"]); expect(run.exitCode).toBe(0); expect(run.stdout).toContain("Measurement is on for this clone"); @@ -103,9 +103,64 @@ describe("aidd telemetry on", () => { }); }); +describe("aidd telemetry on, choosing who is measured", () => { + const readConsent = () => git(repo, box.gitEnv, "config", "--local", "--get", "aidd.telemetry"); + const identityFile = () => join(box.telemetry, "identity.json"); + + it("refuses --yes with no identity set and no choice, and writes nothing", async () => { + const run = await cli(["on", "--yes"]); + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("--anonymous"); + expect(readConsent).toThrow(); + expect(existsSync(identityFile())).toBe(false); + }); + + it("refuses --as with --anonymous, and an identifier it cannot keep, and writes nothing", async () => { + for (const args of [ + ["--as", "Ada Example", "--anonymous"], + ["--as", "Ada\nExample"], + ]) { + expect((await cli(["on", "--yes", ...args])).exitCode).toBe(1); + } + expect(readConsent).toThrow(); + expect(existsSync(identityFile())).toBe(false); + }); + + it("names the person given with --as, in on and in status, and keeps them on the next on", async () => { + const run = await cli(["on", "--yes", "--as", "Ada Example"]); + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain('Measured as "Ada Example".'); + expect(readFileSync(identityFile(), "utf8")).toBe('{"person_id":"Ada Example"}\n'); + expect((await cli(["status"])).stdout).toContain('Measured as "Ada Example".'); + expect((await cli(["on", "--yes"])).stdout).toContain('Measured as "Ada Example".'); + }); + + it("refuses to stay anonymous while an identity is set, pointing to identity --off", async () => { + await cli(["identity", "Ada Example"]); + const run = await cli(["on", "--yes", "--anonymous"]); + expect(run.exitCode).toBe(1); + expect(run.stderr).toContain("aidd telemetry identity --off"); + expect(readConsent).toThrow(); + }); + + it("stays anonymous with --anonymous, stores no choice, and says how to be named", async () => { + const run = await cli(["on", "--yes", "--anonymous"]); + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Measured anonymously"); + expect(existsSync(identityFile())).toBe(false); + expect((await cli(["status"])).stdout).toContain("Measured anonymously."); + }); + + it("turns on under AIDD_TELEMETRY=0 as before, when the choice is given", async () => { + const run = await cli(["on", "--yes", "--anonymous"], { AIDD_TELEMETRY: "0" }); + expect(run.exitCode).toBe(0); + expect(readConsent).not.toThrow(); + }); +}); + describe("aidd telemetry off", () => { it("switches off by setting the git config value to off", async () => { - await cli(["on", "--yes"]); + await cli(["on", "--yes", "--anonymous"]); const run = await cli(["off"]); expect(run.exitCode).toBe(0); expect(run.stdout).toContain("Measurement is off"); @@ -117,7 +172,7 @@ describe("aidd telemetry off", () => { describe("aidd telemetry forget", () => { it("previews, then removes with --yes", async () => { - await cli(["on", "--yes"]); + await cli(["on", "--yes", "--anonymous"]); mkdirSync(join(box.telemetry, "ledger"), { recursive: true }); writeFileSync(join(box.telemetry, "ledger", "2026-10.jsonl"), "{}\n"); writeFileSync(join(box.telemetry, "identity.json"), "{}\n"); @@ -136,7 +191,7 @@ describe("aidd telemetry forget", () => { }); it("leaves no consent behind in a clone where `on` ran and no session ever did", async () => { - await cli(["on", "--yes"]); + await cli(["on", "--yes", "--anonymous"]); expect(git(repo, box.gitEnv, "config", "--local", "--get", "aidd.telemetry").trim()).toMatch( /^2:[0-9a-f-]{36}$/u ); diff --git a/cli/tests/e2e/telemetry-report.e2e.test.ts b/cli/tests/e2e/telemetry-report.e2e.test.ts index dfe5ee5b1..1d75c6275 100644 --- a/cli/tests/e2e/telemetry-report.e2e.test.ts +++ b/cli/tests/e2e/telemetry-report.e2e.test.ts @@ -238,7 +238,7 @@ beforeEach(async () => { await writeFile(join(telemetry, "bindings", "sessions.jsonl"), `${SESSIONS.join("\n")}\n`); await writeFile(join(telemetry, "bindings", "carries.jsonl"), `${CARRIES.join("\n")}\n`); // Opting in is `on`: a key set by hand opens no consent interval, and nothing is stored. - expect((await aidd(["on", "--yes"])).exitCode).toBe(0); + expect((await aidd(["on", "--yes", "--anonymous"])).exitCode).toBe(0); await backdateConsent(telemetry); }); afterEach(async () => { @@ -478,10 +478,10 @@ describe("aidd telemetry report", () => { }); describe("aidd telemetry identity", () => { - it("shows the person axis as not set until an identity is chosen", async () => { + it("shows the person axis as anonymous until an identity is chosen", async () => { const before = rowsOf(await json("person")); expect(Object.keys(before)).toEqual(["absent"]); - expect((await aidd(["report", ...WINDOW, "--axis", "person"])).stdout).toContain("not set"); + expect((await aidd(["report", ...WINDOW, "--axis", "person"])).stdout).toContain("anonymous"); expect((await aidd(["identity"])).stdout).toContain("No identity is set"); }); diff --git a/cli/tests/e2e/telemetry-task.e2e.test.ts b/cli/tests/e2e/telemetry-task.e2e.test.ts index 2788eee4d..ebd9bd2fb 100644 --- a/cli/tests/e2e/telemetry-task.e2e.test.ts +++ b/cli/tests/e2e/telemetry-task.e2e.test.ts @@ -18,7 +18,7 @@ beforeEach(async () => { telemetry = join(env.tempDir, "telemetry-dir"); initRepository(repo, gitEnv, { remote: "git@github.com:acme/widgets.git" }); git(repo, gitEnv, "switch", "-q", "-c", "feat/x"); - await runCli(["telemetry", "on", "--yes"], repo, env.fakeHome, { + await runCli(["telemetry", "on", "--yes", "--anonymous"], repo, env.fakeHome, { env: { AIDD_TELEMETRY_DIR: telemetry, AIDD_TELEMETRY: "" }, }); }); diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index d3cd01093..bb6c3f983 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -162,7 +162,7 @@ { "invocation": "aidd telemetry on", "exitCode": 0, - "help": "Usage: aidd telemetry on [options]\n\nMeasure this clone, and remove what the previous version left in it\n\nOptions:\n --yes Do not ask first (default: false)\n -h, --help display help for command" + "help": "Usage: aidd telemetry on [options]\n\nMeasure this clone, and remove what the previous version left in it\n\nOptions:\n --yes Do not ask first (default: false)\n --as Name you by this identifier on your own measurement\n --anonymous Name nobody on your own measurement (default: false)\n -h, --help display help for command" }, { "invocation": "aidd telemetry report", diff --git a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts index f1fa308bd..304155b66 100644 --- a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts @@ -12,10 +12,13 @@ const turnOn = vi.fn(); const turnOff = vi.fn(); const forget = vi.fn(); const confirm = vi.fn(); +const select = vi.fn(); +const input = vi.fn(); +const propose = vi.fn(); vi.mock("../../../src/runtime/wiring/framework.js", () => ({ createDeps: vi.fn(async () => ({ - prompter: { confirm }, + prompter: { confirm, select, input }, telemetry: { telemetryOnUseCase: { execute: turnOn }, telemetryOffUseCase: { execute: turnOff }, @@ -25,6 +28,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ telemetryStatusUseCase: { execute: show }, reportUsageUseCase: { execute: report }, manageIdentityUseCase: identity, + proposeIdentityUseCase: { execute: propose }, }, })), createMenuDeps: vi.fn(), @@ -284,15 +288,56 @@ const ON = { hook: { lineRemoved: false, delegateRemoved: false, stillCalledBy: [] }, journal: { journalRemoved: false, trackedKept: false, ignoreEntryRemoved: false }, retention: { days: 3650, short: false }, + identity: { personId: null, previous: null }, }; describe("aidd telemetry on", () => { - it("turns measurement on in the directory it was run from, without asking, with --yes", async () => { + const ANONYMOUS = { status: "unset" }; + const NAMED = { status: "set", personId: "Ada Example" }; + beforeEach(() => { + identity.show.mockResolvedValue(ANONYMOUS); turnOn.mockResolvedValue(ON); + }); + + it("turns measurement on in the directory it was run from, without asking, with --yes and a choice", async () => { + expect(await run("on", "--yes", "--anonymous")).toBeNull(); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), "none"); + expect(confirm).not.toHaveBeenCalled(); + expect(stdout()).toContain("Measurement is on"); + }); + + it("names the person given with --as", async () => { + expect(await run("on", "--yes", "--as", "Ada Example")).toBeNull(); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), { set: "Ada Example" }); + }); + + it("keeps the identity already set when --yes is given no choice, and asks nothing", async () => { + identity.show.mockResolvedValue(NAMED); expect(await run("on", "--yes")).toBeNull(); expect(turnOn).toHaveBeenCalledWith(process.cwd(), "keep"); + expect(select).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); + }); + + it("refuses --yes with no identity set and no choice, before anything is written", async () => { + expect(await run("on", "--yes")).toBe(1); + expect(turnOn).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); + expect(stderr()).toContain("--as "); + expect(stderr()).toContain("--anonymous"); + }); + + it("refuses --as with --anonymous, before asking anything", async () => { + expect(await run("on", "--yes", "--as", "Ada Example", "--anonymous")).toBe(1); + expect(identity.show).not.toHaveBeenCalled(); + expect(turnOn).not.toHaveBeenCalled(); + }); + + it("refuses an identifier the identity rules refuse, before asking anything", async () => { + expect(await run("on", "--as", "a".repeat(129))).toBe(1); expect(confirm).not.toHaveBeenCalled(); - expect(stdout()).toContain("Measurement is on"); + expect(turnOn).not.toHaveBeenCalled(); + expect(stderr()).toContain("at most 128 characters"); }); it("refuses to guess an answer where nobody can be asked", async () => { @@ -312,30 +357,82 @@ describe("aidd telemetry on", () => { const original = process.stdout.isTTY; beforeEach(() => { process.stdout.isTTY = true; + confirm.mockResolvedValue(true); + propose.mockResolvedValue("Ada Example"); }); afterEach(() => { process.stdout.isTTY = original; }); + const choices = () => select.mock.calls[0]?.[1] as { name: string; value: unknown }[]; + const offered = () => choices().map((c) => c.value); + it("turns on after a yes, defaulting the question to no", async () => { - confirm.mockResolvedValue(true); - turnOn.mockResolvedValue(ON); + select.mockResolvedValue("none"); expect(await run("on")).toBeNull(); expect(confirm.mock.calls[0]?.[1]).toBe(false); expect(turnOn).toHaveBeenCalled(); }); - it("changes nothing after a no", async () => { + it("changes nothing after a no, and reads no proposal", async () => { confirm.mockResolvedValue(false); expect(await run("on")).toBeNull(); expect(turnOn).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); expect(stdout()).toContain("Nothing changed."); }); + + it("offers the proposal, another identifier, or anonymity, and names the one chosen", async () => { + select.mockImplementation( + async (_message, choices: { value: unknown }[]) => choices[0]?.value + ); + expect(await run("on")).toBeNull(); + expect(propose).toHaveBeenCalledWith(process.cwd()); + expect(offered()).toEqual([{ set: "Ada Example" }, "another", "none"]); + expect(choices()[0]?.name).toContain("Ada Example"); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), { set: "Ada Example" }); + }); + + it("offers no proposal when there is none", async () => { + propose.mockResolvedValue(null); + select.mockResolvedValue("none"); + await run("on"); + expect(offered()).toEqual(["another", "none"]); + }); + + it("takes another identifier, typed", async () => { + select.mockResolvedValue("another"); + input.mockResolvedValue("Ada at Work"); + expect(await run("on")).toBeNull(); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), { set: "Ada at Work" }); + }); + + it("stays anonymous when chosen", async () => { + select.mockResolvedValue("none"); + await run("on"); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), "none"); + }); + + it("asks nothing about the person when an identity is set", async () => { + identity.show.mockResolvedValue(NAMED); + expect(await run("on")).toBeNull(); + expect(select).not.toHaveBeenCalled(); + expect(propose).not.toHaveBeenCalled(); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), "keep"); + }); + + it("asks nothing about the person when a flag answered", async () => { + expect(await run("on", "--anonymous")).toBeNull(); + expect(confirm).toHaveBeenCalled(); + expect(select).not.toHaveBeenCalled(); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), "none"); + }); }); it("exits 1 when it is refused", async () => { - turnOn.mockResolvedValue({ status: "refused", reason: "outside-repository" }); - expect(await run("on", "--yes")).toBe(1); + turnOn.mockResolvedValue({ status: "refused", reason: "identity-set" }); + expect(await run("on", "--yes", "--anonymous")).toBe(1); + expect(stderr()).toContain("aidd telemetry identity --off"); }); }); diff --git a/cli/tests/presentation/display/telemetry-display.unit.test.ts b/cli/tests/presentation/display/telemetry-display.unit.test.ts index 093e4cbaf..81cfd15f8 100644 --- a/cli/tests/presentation/display/telemetry-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry-display.unit.test.ts @@ -26,8 +26,9 @@ describe("printIdentityResult", () => { expect(output.at("success")[0]).toContain("--off"); }); - it("says the person axis is not set when nobody is named", () => { - expect(shown({ status: "unset" }).at("info")[0]).toContain("not set"); + it("says the person axis shows anonymous when nobody is named", () => { + expect(shown({ status: "unset" }).at("info")[0]).toContain('shows "anonymous"'); + expect(shown({ status: "removed" }).at("success")[0]).toContain('shows "anonymous"'); }); it("says the identity was removed", () => { @@ -226,6 +227,7 @@ describe("printTelemetryStatus", () => { branch: "feat/x", role: "working", binding: { state: "unbound" }, + personId: null, ...over, }); const bound = ( @@ -244,6 +246,19 @@ describe("printTelemetryStatus", () => { }, }); + it("says last who the measurement names, whatever the work is bound to", () => { + for (const result of [shown({ personId: "Ada Example" }), bound("branch")]) { + const lines = linesOf((o) => printTelemetryStatus(o, result)); + expect(lines).toHaveLength(3); + } + expect(linesOf((o) => printTelemetryStatus(o, shown({ personId: "Ada Example" })))[2]).toBe( + 'Measured as "Ada Example".' + ); + expect(linesOf((o) => printTelemetryStatus(o, shown({})))[2]).toBe( + "Measured anonymously. To be named, run `aidd telemetry identity `." + ); + }); + it("says first since when the clone is measured", () => { expect(linesOf((o) => printTelemetryStatus(o, shown({})))[0]).toBe( "This clone is measured since 2026-10-01." diff --git a/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts b/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts index f43c9944d..a23b45787 100644 --- a/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts @@ -30,16 +30,29 @@ describe("printOnResult", () => { expect(output.at("success")).toEqual([ "Measurement is on for this clone, from now: calls made before this moment are not counted. `git config aidd.telemetry` holds this clone's own token, in this clone's own config.", ]); - expect(output.lines).toHaveLength(3); + expect(output.lines).toHaveLength(4); expect(output.lines[1]).toContain("never committed"); expect(output.lines[1]).toContain("this clone's linked worktrees"); expect(output.lines.join("\n")).not.toContain("repository"); expect(output.lines[1]).toContain( "a teammate is measured only after running `aidd telemetry on`" ); - expect(output.lines[2]).toMatch(/^Next: declare what you work on with /u); - expect(output.lines[2]).toContain("aidd telemetry task [--ticket ]"); - expect(output.lines[2]).toContain("the aidd-telemetry plugin's hooks ask"); + expect(output.lines[3]).toMatch(/^Next: declare what you work on with /u); + expect(output.lines[3]).toContain("aidd telemetry task [--ticket ]"); + expect(output.lines[3]).toContain("the aidd-telemetry plugin's hooks ask"); + }); + + it("says who the clone is measured as, before the next step", () => { + const named = (personId: string | null, previous: string | null) => + on({ identity: { personId, previous } }).lines.at(-2); + expect(named(null, null)).toBe( + 'Measured anonymously: the person axis shows "anonymous". To be named, run `aidd telemetry identity `.' + ); + expect(named("Ada Example", null)).toBe('Measured as "Ada Example".'); + expect(named("Ada Example", "Ada Example")).toBe('Measured as "Ada Example".'); + expect(named("Ada Example", "Ada at Home")).toBe( + 'Measured as "Ada Example", no longer as "Ada at Home".' + ); }); it("says it already was", () => { diff --git a/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts b/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts index 04c0fdb5b..bda1b108a 100644 --- a/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry/telemetry-report-display.unit.test.ts @@ -167,7 +167,7 @@ describe("printing a usage report", () => { it("says what an absent value is on each axis", () => { const labels: Record = { total: "all", - person: "not set", + person: "anonymous", session: "no session", model: "unknown model", agent: "no agent", diff --git a/scripts/check-claude-tokens-end-to-end.cjs b/scripts/check-claude-tokens-end-to-end.cjs index d55d32aba..3e5e65dbb 100644 --- a/scripts/check-claude-tokens-end-to-end.cjs +++ b/scripts/check-claude-tokens-end-to-end.cjs @@ -346,7 +346,7 @@ async function checkIn({ root, cli, api, log, attended }) { ]) { await step(`git ${args[0]}`, "git", args); } - await aidd("aidd telemetry on", "on", "--yes"); + await aidd("aidd telemetry on", "on", "--yes", "--anonymous"); let session = null; if (attended) { session = await attendedSession({ root, env, project, api, telemetryDir }); From 1cbde1d20a641b751e7f68265497a7a52a79470b Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 22:24:53 +0200 Subject: [PATCH 06/10] docs(telemetry): say what on asks, and how to answer it without a terminal The usage contract, the contributor map and the plugin README describe who is measured at `on`. The 00-init start action reads the identity, asks how the person appears when none is set, offering the git name, and runs `on --yes --as ` or `--anonymous`. Gate evidence recorded. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 20 ++++++-- .../2026_10_15_identity-at-opt-in/evidence.md | 47 +++++++++++++++++++ .../2026_10_15_identity-at-opt-in/phase-3.md | 2 +- cli/aidd_docs/memory/telemetry.md | 1 + plugins/aidd-telemetry/README.md | 7 +-- .../aidd-telemetry/skills/00-init/SKILL.md | 2 +- .../skills/00-init/actions/start.md | 22 ++++++--- 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 384fb62a9..41410c593 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -102,6 +102,7 @@ Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (eac - Every axis partitions the same calls: each adds up to `totals`, counter by counter. - `AIDD_TELEMETRY=0`: nothing read, object is `{"version": 1, "refused": "AIDD_TELEMETRY=0"}`. +- The text report labels an `absent` person `anonymous`; the envelope keeps `kind: absent`, `key: null`. - A report ingests first. `files_read` and `unrecognised_shapes` describe that run; `records` the period. - A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of one call have been observed less than a day apart, and under that bound both print the same figures. Outside it, only a report with no period folds every copy of a call. `skipped_ledger_lines` counts the lines of the partitions read. - No path, working directory or branch. `session` keys are full session ids, `repository` keys are hashes. @@ -122,7 +123,7 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with branch creation time from its reflog | | `bindings/carries.jsonl` | `SessionStart` hook | hooks, `report` | `{session_id, from, at}` | | `bindings/processes.jsonl` | `SessionStart` hook | `SessionStart` hook | `{pid, session_id, source, at}` | -| `identity.json` | `identity` | `report` | `{"person_id": ""}`, owner-only | +| `identity.json` | `identity`, `on` | `report`, `status`, `on` | `{"person_id": ""}`, owner-only | | git config `--local` `branch..aiddTask`, `aiddTicket`, `aiddDeclaredAt` | `task` | hooks, `task`, `report` via snapshots | branch declaration; follows a rename, leaves with the branch | | git config `--local` `aidd.telemetry` | `on`, `off`, `forget` | hooks, CLI | `2:` or `off`. Half of consent: alone it grants nothing | @@ -234,7 +235,7 @@ stateDiagram-v2 | Actor | Does | | --- | --- | -| `on` | under the ledger lock: fresh token, key `2:` first, then open line (a key with no interval measures nothing; an interval its key does not name would be closed by the first hook). Key naming an open interval of this clone: no change. Otherwise closes this clone's stale open intervals at its time. Refuses a clone with no identity | +| `on` | under the ledger lock: fresh token, key `2:` first, then open line (a key with no interval measures nothing; an interval its key does not name would be closed by the first hook). Key naming an open interval of this clone: no change. Otherwise closes this clone's stale open intervals at its time. Refuses a clone with no identity. Then, only once on, writes the person's identity when one was chosen | | `off` | under the same lock: close the clone's open token, then key `off`; key and interval change together, so an ingest never sees one without the other. Also closes the interval its key names at its path, whatever identity it reads there | | ingest | before judging any call, for every open interval, closes at observation time when the identity at its path changed or the live key is not `2:`. Nothing at the path: not closed (unmounted volume, folder moved and back); measured again when back. Unreadable config or `stat`: stays open, stores nothing | | hooks | after the Claude-only guard, even headless: key not naming an open interval recorded for this clone's real path => append a close at hook time. Ends a manual `git config aidd.telemetry off` or `--unset` before the next prompt's calls. `AIDD_TELEMETRY=0`: does nothing | @@ -280,5 +281,18 @@ Release 1 opens no network connection, in the telemetry context or the hooks. `f - Local only: ledger `cwd` and `git_branch`, roots, process facts, transcripts. - Text report: no path, branch, working directory or whole session id (short keys); envelope per [Report envelope](#report-envelope). `task`, `status`, `forget` print what they act on (a branch: `Branch feat/x is bound.`; `forget` lists entries and repositories); the lock error names the lock file. -- Identity only if chosen with `aidd telemetry identity `; labels the `person` axis only. +- Identity only if chosen: `aidd telemetry identity `, `on --as `, or the answer to `on`'s question. Labels the `person` axis only; `on` and `status` name it. - No currency amount: the destination owns the price table. + +**Who is measured, at `on`.** + +| Case | `on` does | +| --- | --- | +| identity set, no flag | keeps it, asks nothing | +| none set, a terminal | after the opt-in is confirmed, asks: git's `user.name` in that directory, else `user.email` (offered only when `personIdOf` accepts it, never under `AIDD_TELEMETRY=0`), another identifier typed, or anonymous | +| `--as ` | sets it, replacing one set; refused before anything is written when not one line of 1 to 128 characters | +| `--anonymous` | sets nothing; refused while an identity is set (`identity --off` first) | +| `--yes`, none set, no flag | refused, exit 1, nothing written | +| `--as` with `--anonymous` | refused, exit 1 | + +A refused `on` writes neither consent nor identity. Staying anonymous is not stored: the next `on`, in any clone, asks again. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md index b5215ec50..ff73659ed 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md @@ -164,3 +164,50 @@ Tests 1 failed | 29 passed (30) Test Files 505 passed | 1 skipped (506) Tests 6553 passed | 1 skipped (6554) ``` + +### Phase 3: gates + +Every run of the built binary was sandboxed: `pnpm smoke` relocates `HOME` and sets `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR` itself, and was given a temporary `XDG_CONFIG_HOME` and `GIT_CONFIG_NOSYSTEM=1`; `check-claude-tokens-end-to-end.cjs` builds its own temporary home. + +`pnpm smoke` (builds first, so the bundle budget runs with it): + +```txt +Bundle size: 686.2 KB / budget: 695 KB +=== telemetry (sandboxed profile) === + ✓ telemetry on +PASS: 132 FAIL: 0 SKIP: 1 · coverage 100% +``` + +`node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js`, exit 0: + +```txt +2 calls, 393 tokens, all on task demo, ticket T-1 +``` + +`node scripts/check-claude-tokens-end-to-end.cjs --attended cli/dist/cli.js`, exit 0: + +```txt +2 calls, 393 tokens, all on task demo, ticket T-1; asked, answered by the hook, kept across /clear +``` + +`pnpm knip`: no finding. + +`pnpm test:mutation:telemetry` (49 minutes, run alongside the other gates): + +```txt +3998 mutants, 136 survived, 28 timed out +Report: reports/mutation/telemetry/ (score 96.2, floor 96) +``` + +No mutant survives on a line this change added or modified: the survivors in the files it touched are on lines it left alone (`telemetry-on-use-case.ts` 153 and 155, inside `grant`; `telemetry-lifecycle-display.ts` 149, 167, 168, the forget listing; `telemetry-report-display.ts` 91, 102, 149; `person-identity.ts` 22). The new files (`propose-identity-use-case.ts`, `git-user-source-adapter.ts`) have every mutant killed: 6 of 6 and 13 of 13. + +`pnpm exec lefthook run pre-commit`: every command green (`cli-architecture`, `cli-biome`, `cli-typecheck`, `markdown-links`, `scripts-tests`, …), `summary: (done in 38.91 seconds)`. + +`pnpm exec lefthook run pre-push`, which runs the whole CLI suite (`cd cli && pnpm test`): + +```txt +Test Files 505 passed | 1 skipped (506) + Tests 6553 passed | 1 skipped (6554) +✔️ cli-knip +✔️ cli-test +``` diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-3.md index 2733c255e..50b90a886 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-3.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/phase-3.md @@ -1,5 +1,5 @@ --- -status: todo +status: done --- # Instruction: Docs, skill and gates diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index f6b86a096..2c2971dd8 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -57,6 +57,7 @@ flowchart LR | How the ledger lies on disk, and what an ingest or a report reads | `infrastructure/usage-ledger-adapter.ts`, `domain/stored-usage.ts`, `application/ledger-upkeep.ts`, `domain/report/period.ts` | | What `on` and `forget` clean up from an earlier measurement | `domain/legacy/`, `application/switch/`, `application/forget/` | | The report axes and the envelope | `domain/report/usage-report.ts`, `application/report/report-envelope.ts` | +| Who the measurement names, and what `on` offers | `domain/identity/person-identity.ts`, `application/identity/`, `infrastructure/identity/git-user-source-adapter.ts`; the question is the `on` command's | ## The plugin side diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index 20a59b4cd..ab897214c 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -25,7 +25,7 @@ flowchart LR ## Start 1. Install: `npm install -g @ai-driven-dev/cli`, then `aidd plugin install aidd-telemetry`. Its hooks need `node` on `PATH`. -2. In each clone to measure: `aidd telemetry on`. +2. In each clone to measure: `aidd telemetry on`. With no identity set, it asks how you appear: your git name, another identifier, or anonymous. Without a terminal, answer with `--yes --as ` or `--yes --anonymous`. 3. Work. When the work has no task yet, Claude Code asks once: send `aidd telemetry task [--ticket ]` (or `--none`) as your next prompt, or run it in a terminal. 4. Read: `aidd telemetry report --axis task`. @@ -33,8 +33,8 @@ flowchart LR | Command | Does | | --- | --- | -| `aidd telemetry on` / `off` | start or stop measuring this clone; what was measured stays | -| `aidd telemetry status` | whether this clone is measured, since when, and its task | +| `aidd telemetry on` / `off` | start or stop measuring this clone; what was measured stays. `on --as ` names you, `on --anonymous` names nobody | +| `aidd telemetry status` | whether this clone is measured, since when, its task, and under which name | | `aidd telemetry task [--ticket ]` | declare the task; `--none` for no task | | `aidd telemetry report [--axis ] [--days ]` | what the work consumed, split along one axis; `--json` for the envelope | | `aidd telemetry identity ` / `--off` | be named on your own measurement, or not | @@ -51,4 +51,5 @@ flowchart LR - Consent is per clone and never committed: a teammate who pulls is not measured until they run `on`. - Data stays in `~/.config/aidd/telemetry` and the clone's git config; `forget --yes` erases it. +- You are named only by choosing to be, at `on` or with `identity`. Otherwise the person axis reads `anonymous`; that choice is not stored, so the next `on` asks again. - `AIDD_TELEMETRY=0` refuses measurement everywhere. diff --git a/plugins/aidd-telemetry/skills/00-init/SKILL.md b/plugins/aidd-telemetry/skills/00-init/SKILL.md index dfe4b4236..e38943e16 100644 --- a/plugins/aidd-telemetry/skills/00-init/SKILL.md +++ b/plugins/aidd-telemetry/skills/00-init/SKILL.md @@ -36,5 +36,5 @@ Run the flow above. Read only the action file the case needs. - Every change goes through `aidd telemetry`. Never edit git config, `.aidd/config.json` or any file by hand. - Never run a command that changes anything before the person has said yes to what it does. A silence, a previous yes to another case, or a standing instruction is not a yes. - Report what each command printed, including every warning, and never claim a result the output did not show. -- Never choose, derive or guess a name for the person: not from git, from the machine, nor from the session. +- Never choose or guess a name for the person. The name git gives may be offered, as `aidd telemetry on` offers it, and is used only when the person picks it; nothing from the machine or the session is. - When a command exits non-zero, show its message and stop. Do not retry with other flags. diff --git a/plugins/aidd-telemetry/skills/00-init/actions/start.md b/plugins/aidd-telemetry/skills/00-init/actions/start.md index b28760a79..bf5151dad 100644 --- a/plugins/aidd-telemetry/skills/00-init/actions/start.md +++ b/plugins/aidd-telemetry/skills/00-init/actions/start.md @@ -8,7 +8,7 @@ The project directory, which must be inside a git repository. ## Output -Measurement is on for the project, or nothing changed and the reason is stated. The person knows what is measured, what they will be asked, and where it stays. +Measurement is on for the project, or nothing changed and the reason is stated. The person knows what is measured, what they will be asked, where it stays, and under which name, if any, they are measured. ## Process @@ -20,19 +20,27 @@ Measurement is on for the project, or nothing changed and the reason is stated. - whose consent it is: this clone's alone, kept in its git config and never committed, so a teammate is not measured until they run it in theirs. 2. **Ask.** Ask whether to turn measurement on for this project. Wait for an explicit yes. - A no, or no answer, ends the action with nothing changed. -3. **Turn on.** Run `aidd telemetry on --yes`. The yes was given in the previous step, so the command's own confirmation is skipped. - - Outside a git repository, or with a git config git cannot read, the command refuses: show its message and stop. -4. **Report.** Relay everything the command printed, the removals and the warnings included, in the person's words rather than pasted. +3. **Choose the name.** Run `aidd telemetry identity` to read whether an identity is already set. + - Set: say the person is measured under it, and that the `identity` case changes or removes it. Nothing to ask. + - Not set: explain that a name only labels the person axis of their own report and stays on this machine, then ask how they want to appear. Offer the name git gives in this project (`git config user.name`, else `git config user.email`) when there is one, another identifier they type, or anonymous. Wait for the answer; never pick for them. +4. **Turn on.** The yes was given in step 2, so the command's own confirmation is skipped: + - identity already set: `aidd telemetry on --yes`; + - a name chosen: `aidd telemetry on --yes --as `, with exactly the text chosen; + - anonymous: `aidd telemetry on --yes --anonymous`. Staying anonymous is not stored: the next `on`, in another clone, asks again. + - Outside a git repository, with a git config git cannot read, or with an identifier over 128 characters or spanning lines, the command refuses: show its message and stop. +5. **Report.** Relay everything the command printed, the removals and the warnings included, in the person's words rather than pasted. - When it warned that Claude Code keeps session files only briefly, say that a call is measured only if it is read before the files are removed, and give the setting it named. -5. **Prove.** Run `aidd telemetry report --days 1`, and state what it shows. +6. **Prove.** Run `aidd telemetry report --days 1`, and state what it shows. - No calls yet is a normal answer for a project that has just opted in: say so, and that the next Claude Code session in this project will be counted. -6. **Tell the rest.** Say that the plugin is what asks about the task and that it needs `node`, and that a task can always be declared by hand with `aidd telemetry task [--ticket ]` or `aidd telemetry task --none`, and that `aidd telemetry status` shows whether a clone is measured and its task. +7. **Tell the rest.** Say that the plugin is what asks about the task and that it needs `node`, and that a task can always be declared by hand with `aidd telemetry task [--ticket ]` or `aidd telemetry task --none`, and that `aidd telemetry status` shows whether a clone is measured and its task. ## Test | Case | Pass | | --- | --- | | The person says no | No `aidd telemetry on` was run and the project is unchanged | -| The person says yes | `aidd telemetry on --yes` ran once, the person was told that measurement starts now and that earlier history is not counted, and the command printed that the clone's consent is set; if it said it removed or deleted a block in `.aidd/config.json`, the person was told that file may need committing | +| The person says yes, with no identity set | They were asked how to appear, offered the git name when there is one, and exactly one of `aidd telemetry on --yes --as `, with the text they chose, or `aidd telemetry on --yes --anonymous` ran | +| The person says yes, with an identity set | They were asked nothing about a name, were told the one they are measured under, and `aidd telemetry on --yes` ran | +| The person says yes | `aidd telemetry on` ran once, the person was told that measurement starts now and that earlier history is not counted, and the command printed that the clone's consent is set; if it said it removed or deleted a block in `.aidd/config.json`, the person was told that file may need committing | | `aidd` has no `telemetry` | The skill said so and ran nothing else | | Run outside a git repository | The refusal was shown and nothing was written | From 390911510e62910e9810b9a5aaee9ef77a8684f8 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 15 Oct 2026 22:46:26 +0200 Subject: [PATCH 07/10] fix(telemetry): ask again for an identifier on cannot keep A typed identifier the identity rules refuse is asked again instead of ending `on` after the opt-in was confirmed. The refusal and the prompt say "one non-empty line of at most 128 characters", and the command prints the refusal through the display instead of a copy of its text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/evidence.md | 36 +++++++++++++++++++ cli/src/presentation/commands/telemetry.ts | 7 ++-- .../presentation/display/telemetry-display.ts | 2 +- .../telemetry/telemetry-lifecycle-display.ts | 3 +- .../telemetry-wiring.integration.test.ts | 19 +++++++++- .../display/telemetry-display.unit.test.ts | 4 ++- .../telemetry-lifecycle-display.unit.test.ts | 2 +- 7 files changed, 66 insertions(+), 7 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md index ff73659ed..ef9d172ca 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md @@ -211,3 +211,39 @@ Test Files 505 passed | 1 skipped (506) ✔️ cli-knip ✔️ cli-test ``` + +## Review round 1 fixes + +### Red run + +`pnpm exec vitest run tests/presentation/display tests/presentation/commands/telemetry-wiring.integration.test.ts`, before the refusal says "non-empty", before the command prints it through `printOnResult`, and before a typed identifier is asked again: + +```txt +× explains what an identity is when one is refused + AssertionError: expected 'An identity is one line of at most 12…' to be 'An identity is one non-empty line of …' +× refuses an identity it cannot keep, and says nothing changed + AssertionError: expected 'An identity is one line of at most 12…' to be 'An identity is one non-empty line of …' +× refuses an identifier the identity rules refuse, before asking anything + AssertionError: expected 'Error: An identity is one line of at …' to contain 'An identity is one non-empty line of …' +× takes another identifier, typed + AssertionError: expected 'Identifier (one line, at most 128 cha…' to be 'Identifier (one non-empty line, at mo…' +× asks again until the identifier typed is one it can keep + AssertionError: expected "vi.fn()" to be called 3 times, but got 1 times +Tests 5 failed +``` + +### Guard + +The typed identifier taken without checking it (`return { set: typed };`), reverted after the run: + +```txt +× asks again until the identifier typed is one it can keep +Tests 1 failed | 45 passed (46) +``` + +### Gates + +- `cd cli && pnpm test`: `Test Files 505 passed | 1 skipped (506)`, `Tests 6554 passed | 1 skipped (6555)`. +- `pnpm smoke`, sandboxed as before: `Bundle size: 686.3 KB / budget: 695 KB`, `PASS: 132 FAIL: 0 SKIP: 1`. +- `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js`, exit 0: `2 calls, 393 tokens, all on task demo, ticket T-1`. +- Same with `--attended`, exit 0: `… asked, answered by the hook, kept across /clear`. diff --git a/cli/src/presentation/commands/telemetry.ts b/cli/src/presentation/commands/telemetry.ts index ca9cfb80f..7f5be3bf8 100644 --- a/cli/src/presentation/commands/telemetry.ts +++ b/cli/src/presentation/commands/telemetry.ts @@ -63,7 +63,10 @@ async function identityChoice( ] ); if (answer !== "another") return answer; - return { set: await prompter.input("Identifier (one line, at most 128 characters):") }; + for (;;) { + const typed = await prompter.input("Identifier (one non-empty line, at most 128 characters):"); + if (personIdOf(typed) !== null) return { set: typed }; + } } export function registerTelemetryCommand(program: Command): void { @@ -84,7 +87,7 @@ export function registerTelemetryCommand(program: Command): void { process.exit(1); } if (cmdOptions.as !== undefined && personIdOf(cmdOptions.as) === null) { - output.error("An identity is one line of at most 128 characters. Nothing was changed."); + printOnResult(output, { status: "refused", reason: "invalid-identity" }); process.exit(1); } if (!cmdOptions.yes && !process.stdout.isTTY) { diff --git a/cli/src/presentation/display/telemetry-display.ts b/cli/src/presentation/display/telemetry-display.ts index 738669cad..fd46f1ae1 100644 --- a/cli/src/presentation/display/telemetry-display.ts +++ b/cli/src/presentation/display/telemetry-display.ts @@ -168,6 +168,6 @@ export function printIdentityResult(output: CLIOutput, result: IdentityResult): ); return; case "refused": - output.error("An identity is one line of at most 128 characters."); + output.error("An identity is one non-empty line of at most 128 characters."); } } diff --git a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts index 4f79e264f..c664e8604 100644 --- a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts +++ b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts @@ -22,7 +22,8 @@ const REFUSALS = { "ledger/consents.jsonl holds a line that is not a consent event, so the log cannot be trusted and nothing was changed. Run `aidd telemetry forget --yes`, then `aidd telemetry on` again: nothing from before is stored.", "unidentified-clone": "This file system gives the clone's git directory no identity, so nothing measured here could be told to be its own. Nothing was changed.", - "invalid-identity": "An identity is one line of at most 128 characters. Nothing was changed.", + "invalid-identity": + "An identity is one non-empty line of at most 128 characters. Nothing was changed.", "identity-set": "An identity is set, so this clone cannot be measured anonymously. Remove it with `aidd telemetry identity --off`, then run this again. Nothing was changed.", } as const; diff --git a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts index 304155b66..f363c2d2c 100644 --- a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts @@ -337,7 +337,9 @@ describe("aidd telemetry on", () => { expect(await run("on", "--as", "a".repeat(129))).toBe(1); expect(confirm).not.toHaveBeenCalled(); expect(turnOn).not.toHaveBeenCalled(); - expect(stderr()).toContain("at most 128 characters"); + expect(stderr()).toContain( + "An identity is one non-empty line of at most 128 characters. Nothing was changed." + ); }); it("refuses to guess an answer where nobody can be asked", async () => { @@ -404,6 +406,21 @@ describe("aidd telemetry on", () => { select.mockResolvedValue("another"); input.mockResolvedValue("Ada at Work"); expect(await run("on")).toBeNull(); + expect(input.mock.calls[0]?.[0]).toBe( + "Identifier (one non-empty line, at most 128 characters):" + ); + expect(turnOn).toHaveBeenCalledWith(process.cwd(), { set: "Ada at Work" }); + }); + + it("asks again until the identifier typed is one it can keep", async () => { + select.mockResolvedValue("another"); + input + .mockResolvedValueOnce(" ") + .mockResolvedValueOnce("a".repeat(129)) + .mockResolvedValueOnce("Ada at Work"); + expect(await run("on")).toBeNull(); + expect(input).toHaveBeenCalledTimes(3); + expect(turnOn).toHaveBeenCalledTimes(1); expect(turnOn).toHaveBeenCalledWith(process.cwd(), { set: "Ada at Work" }); }); diff --git a/cli/tests/presentation/display/telemetry-display.unit.test.ts b/cli/tests/presentation/display/telemetry-display.unit.test.ts index 81cfd15f8..a6d6ed9c1 100644 --- a/cli/tests/presentation/display/telemetry-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry-display.unit.test.ts @@ -36,7 +36,9 @@ describe("printIdentityResult", () => { }); it("explains what an identity is when one is refused", () => { - expect(shown({ status: "refused" }).at("error")[0]).toContain("one line"); + expect(shown({ status: "refused" }).at("error")[0]).toBe( + "An identity is one non-empty line of at most 128 characters." + ); }); }); diff --git a/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts b/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts index a23b45787..62358cbb4 100644 --- a/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts @@ -131,7 +131,7 @@ describe("printOnResult", () => { const output = new CapturingOutput(false); printOnResult(output, { status: "refused", reason: "invalid-identity" }); expect(output.at("error")[0]).toBe( - "An identity is one line of at most 128 characters. Nothing was changed." + "An identity is one non-empty line of at most 128 characters. Nothing was changed." ); }); From a770739ddec5619935402bdb6e74af082b81c4d0 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 05:41:02 +0200 Subject: [PATCH 08/10] docs(telemetry): the start action reads no git name, and probes on --help The 00-init skill asks the person to type an identifier or stay anonymous, runs `on --yes --as ''` quoted or `--anonymous`, and says that `aidd telemetry on` in a terminal offers the git name: no name reaches the session before the person chooses. Its probe now requires `--anonymous` in `aidd telemetry on --help`. The start test table loses the row its two new rows cover. Contract rules 5 and 6 say what ships. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/contract.md | 6 +++--- .../2026_10_15_identity-at-opt-in/evidence.md | 1 + plugins/aidd-telemetry/skills/00-init/SKILL.md | 4 ++-- .../aidd-telemetry/skills/00-init/actions/start.md | 12 ++++++------ 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md index 0dae83fe4..f31a95bb2 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md @@ -36,8 +36,8 @@ issue: 1029 | 2 | A proposal or a typed identifier goes through `personIdOf` (trimmed, one line, 1 to 128 characters). A proposal it rejects is not offered. A rejected `--as` is an error before anything is written. | | 3 | `--as` and `--anonymous` together: error, exit 1. Either with `--yes` or interactive is accepted. | | 4 | The identity is written only when `on` succeeds (status `on`). A refused `on` writes no identity. | -| 5 | `AIDD_TELEMETRY=0` refuses as today, before git is asked for a proposal. | -| 6 | `identity`, `identity ` and `identity --off` are unchanged. | +| 5 | `AIDD_TELEMETRY=0` does not refuse `on`, as before this change. Under it, the git name is neither read nor proposed: the question offers only another identifier or anonymous. | +| 6 | `identity`, `identity ` and `identity --off` behave as before; their wording says `anonymous` for nobody, as the report does (decision 1). | ```mermaid flowchart TD @@ -60,7 +60,7 @@ The identity store format, `identity.json` and where it lives; the envelope; con ## Docs -Usage contract (person axis, opt-in), `cli/aidd_docs/memory/telemetry.md`, `plugins/aidd-telemetry/README.md`, and the `00-init` skill's `start` action: it asks the person how to appear, offering the git name, and runs `aidd telemetry on --yes --as ` or `--anonymous`. +Usage contract (person axis, opt-in), `cli/aidd_docs/memory/telemetry.md`, `plugins/aidd-telemetry/README.md`, and the `00-init` skill's `start` action: it asks the person to type an identifier or stay anonymous, reads nothing from git so no name reaches the session before the person chooses, says that `aidd telemetry on` in a terminal offers the git name, and runs `aidd telemetry on --yes --as ''` or `--anonymous`. ## Acceptance diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md index ef9d172ca..bd0c59c92 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md @@ -247,3 +247,4 @@ Tests 1 failed | 45 passed (46) - `pnpm smoke`, sandboxed as before: `Bundle size: 686.3 KB / budget: 695 KB`, `PASS: 132 FAIL: 0 SKIP: 1`. - `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js`, exit 0: `2 calls, 393 tokens, all on task demo, ticket T-1`. - Same with `--attended`, exit 0: `… asked, answered by the hook, kept across /clear`. +- `pnpm test:mutation:telemetry`, on the final code: `Report: reports/mutation/telemetry/ (score 96.2, floor 96)`. The one changed line in its scope (`telemetry-lifecycle-display.ts` 25-26, the refusal text) has no survivor; the file's survivors are the same untouched forget-listing lines as before, shifted by one (150, 168, 169). `commands/telemetry.ts` is in the `presentation` scope; its loop is proven by the guard above. diff --git a/plugins/aidd-telemetry/skills/00-init/SKILL.md b/plugins/aidd-telemetry/skills/00-init/SKILL.md index e38943e16..ba79d2197 100644 --- a/plugins/aidd-telemetry/skills/00-init/SKILL.md +++ b/plugins/aidd-telemetry/skills/00-init/SKILL.md @@ -32,9 +32,9 @@ Run the flow above. Read only the action file the case needs. ## Transversal rules -- Probe first. Run `aidd telemetry --help` before any action. When `aidd` is not found, or the command exits non-zero, stop and say plainly that `aidd` is missing or too old to measure, name `@ai-driven-dev/cli` as what to install or update, and state that nothing was changed. Never go on, and never answer as if it had worked. +- Probe first. Run `aidd telemetry on --help` before any action. When `aidd` is not found, the command exits non-zero, or its help does not list `--anonymous`, stop and say plainly that `aidd` is missing or too old to measure, name `@ai-driven-dev/cli` as what to install or update, and state that nothing was changed. Never go on, and never answer as if it had worked. - Every change goes through `aidd telemetry`. Never edit git config, `.aidd/config.json` or any file by hand. - Never run a command that changes anything before the person has said yes to what it does. A silence, a previous yes to another case, or a standing instruction is not a yes. - Report what each command printed, including every warning, and never claim a result the output did not show. -- Never choose or guess a name for the person. The name git gives may be offered, as `aidd telemetry on` offers it, and is used only when the person picks it; nothing from the machine or the session is. +- Never choose, derive or guess a name for the person: not from git, from the machine, nor from the session. Only the identifier the person types is used. `aidd telemetry on` run in a terminal offers the git name itself. - When a command exits non-zero, show its message and stop. Do not retry with other flags. diff --git a/plugins/aidd-telemetry/skills/00-init/actions/start.md b/plugins/aidd-telemetry/skills/00-init/actions/start.md index bf5151dad..947fbdf0a 100644 --- a/plugins/aidd-telemetry/skills/00-init/actions/start.md +++ b/plugins/aidd-telemetry/skills/00-init/actions/start.md @@ -22,12 +22,12 @@ Measurement is on for the project, or nothing changed and the reason is stated. - A no, or no answer, ends the action with nothing changed. 3. **Choose the name.** Run `aidd telemetry identity` to read whether an identity is already set. - Set: say the person is measured under it, and that the `identity` case changes or removes it. Nothing to ask. - - Not set: explain that a name only labels the person axis of their own report and stays on this machine, then ask how they want to appear. Offer the name git gives in this project (`git config user.name`, else `git config user.email`) when there is one, another identifier they type, or anonymous. Wait for the answer; never pick for them. + - Not set: explain that a name only labels the person axis of their own report and stays on this machine, then ask the person to type the identifier they want, or to stay anonymous. Wait for the answer; never suggest or pick one for them. Say that `aidd telemetry on`, run in their own terminal, offers their git name instead. 4. **Turn on.** The yes was given in step 2, so the command's own confirmation is skipped: - identity already set: `aidd telemetry on --yes`; - - a name chosen: `aidd telemetry on --yes --as `, with exactly the text chosen; + - a name typed: `aidd telemetry on --yes --as ''`, with exactly the text typed, quoted for the shell; - anonymous: `aidd telemetry on --yes --anonymous`. Staying anonymous is not stored: the next `on`, in another clone, asks again. - - Outside a git repository, with a git config git cannot read, or with an identifier over 128 characters or spanning lines, the command refuses: show its message and stop. + - Outside a git repository, with a git config git cannot read, or with an identifier that is not one non-empty line of at most 128 characters, the command refuses: show its message and stop. 5. **Report.** Relay everything the command printed, the removals and the warnings included, in the person's words rather than pasted. - When it warned that Claude Code keeps session files only briefly, say that a call is measured only if it is read before the files are removed, and give the setting it named. 6. **Prove.** Run `aidd telemetry report --days 1`, and state what it shows. @@ -39,8 +39,8 @@ Measurement is on for the project, or nothing changed and the reason is stated. | Case | Pass | | --- | --- | | The person says no | No `aidd telemetry on` was run and the project is unchanged | -| The person says yes, with no identity set | They were asked how to appear, offered the git name when there is one, and exactly one of `aidd telemetry on --yes --as `, with the text they chose, or `aidd telemetry on --yes --anonymous` ran | -| The person says yes, with an identity set | They were asked nothing about a name, were told the one they are measured under, and `aidd telemetry on --yes` ran | -| The person says yes | `aidd telemetry on` ran once, the person was told that measurement starts now and that earlier history is not counted, and the command printed that the clone's consent is set; if it said it removed or deleted a block in `.aidd/config.json`, the person was told that file may need committing | +| The person says yes, with no identity set | They were asked to type an identifier or stay anonymous, no name was suggested and git was not read, exactly one of `aidd telemetry on --yes --as ''`, with the text they typed, or `aidd telemetry on --yes --anonymous` ran, and the person was told that measurement starts now, that earlier history is not counted, and, if the command removed or deleted a block in `.aidd/config.json`, that the file may need committing | +| The person says yes, with an identity set | They were asked nothing about a name, were told the one they are measured under, `aidd telemetry on --yes` ran once, and the person was told the same as above about when measurement starts and `.aidd/config.json` | +| The CLI's `on` has no `--anonymous` | The skill said to update `@ai-driven-dev/cli` and ran nothing else | | `aidd` has no `telemetry` | The skill said so and ran nothing else | | Run outside a git repository | The refusal was shown and nothing was written | From 39afc5917354fba85fcd0ae5e612855b824f72e7 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 06:00:26 +0200 Subject: [PATCH 09/10] fix(telemetry): say why a typed identifier is asked for again A refused typed identifier now prints the identity rule before the prompt repeats. The start action says how to keep a quote inside the single-quoted --as value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/evidence.md | 14 ++++++++++++++ cli/src/presentation/commands/telemetry.ts | 12 +++++++++++- .../telemetry/telemetry-lifecycle-display.ts | 10 ++++++++-- .../commands/telemetry-wiring.integration.test.ts | 2 ++ .../aidd-telemetry/skills/00-init/actions/start.md | 2 +- 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md index bd0c59c92..760197849 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md @@ -248,3 +248,17 @@ Tests 1 failed | 45 passed (46) - `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js`, exit 0: `2 calls, 393 tokens, all on task demo, ticket T-1`. - Same with `--attended`, exit 0: `… asked, answered by the hook, kept across /clear`. - `pnpm test:mutation:telemetry`, on the final code: `Report: reports/mutation/telemetry/ (score 96.2, floor 96)`. The one changed line in its scope (`telemetry-lifecycle-display.ts` 25-26, the refusal text) has no survivor; the file's survivors are the same untouched forget-listing lines as before, shifted by one (150, 168, 169). `commands/telemetry.ts` is in the `presentation` scope; its loop is proven by the guard above. + +## Review round 2 fixes + +`pnpm exec vitest run tests/presentation/commands/telemetry-wiring.integration.test.ts`, before a refused typed identifier is explained before it is asked again: + +```txt +× asks again until the identifier typed is one it can keep + AssertionError: expected [ '' ] to have a length of 3 but got 1 +Tests 1 failed | 45 passed (46) +``` + +Green after: `tests/presentation`, `Tests 610 passed (610)`. + +Guard: with `printIdentityRule(output);` removed from the loop, `× asks again until the identifier typed is one it can keep`, `Tests 1 failed | 45 passed (46)`; reverted. diff --git a/cli/src/presentation/commands/telemetry.ts b/cli/src/presentation/commands/telemetry.ts index 7f5be3bf8..e36504773 100644 --- a/cli/src/presentation/commands/telemetry.ts +++ b/cli/src/presentation/commands/telemetry.ts @@ -20,6 +20,7 @@ import { createDeps } from "../../runtime/wiring/framework.js"; import type { TelemetryDeps } from "../../runtime/wiring/telemetry.js"; import { printForgetResult, + printIdentityRule, printOffResult, printOnResult, } from "../display/telemetry/telemetry-lifecycle-display.js"; @@ -31,6 +32,7 @@ import { printTelemetryStatus, } from "../display/telemetry-display.js"; import { ErrorHandler } from "../error-handler.js"; +import type { CLIOutput } from "../output.js"; import { parseGlobalOptions } from "./global-options.js"; interface OnOptions { @@ -44,6 +46,7 @@ interface OnOptions { async function identityChoice( telemetry: TelemetryDeps, prompter: Prompter, + output: CLIOutput, cwd: string, options: OnOptions ): Promise { @@ -66,6 +69,7 @@ async function identityChoice( for (;;) { const typed = await prompter.input("Identifier (one non-empty line, at most 128 characters):"); if (personIdOf(typed) !== null) return { set: typed }; + printIdentityRule(output); } } @@ -106,7 +110,13 @@ export function registerTelemetryCommand(program: Command): void { output.info("Nothing changed."); return; } - const choice = await identityChoice(deps.telemetry, deps.prompter, projectRoot, cmdOptions); + const choice = await identityChoice( + deps.telemetry, + deps.prompter, + output, + projectRoot, + cmdOptions + ); if (choice === null) { output.error( "No identity is set, and --yes asks nothing: pass --as to be named, or --anonymous to name nobody." diff --git a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts index c664e8604..522da6eb1 100644 --- a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts +++ b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts @@ -14,6 +14,8 @@ import { } from "../../../contexts/telemetry/domain/switch/claude-retention.js"; import type { CLIOutput } from "../../output.js"; +const IDENTITY_RULE = "An identity is one non-empty line of at most 128 characters."; + const REFUSALS = { "outside-repository": "This directory is not inside a git repository.", "unreadable-git-config": @@ -22,8 +24,7 @@ const REFUSALS = { "ledger/consents.jsonl holds a line that is not a consent event, so the log cannot be trusted and nothing was changed. Run `aidd telemetry forget --yes`, then `aidd telemetry on` again: nothing from before is stored.", "unidentified-clone": "This file system gives the clone's git directory no identity, so nothing measured here could be told to be its own. Nothing was changed.", - "invalid-identity": - "An identity is one non-empty line of at most 128 characters. Nothing was changed.", + "invalid-identity": `${IDENTITY_RULE} Nothing was changed.`, "identity-set": "An identity is set, so this clone cannot be measured anonymously. Remove it with `aidd telemetry identity --off`, then run this again. Nothing was changed.", } as const; @@ -40,6 +41,11 @@ function identityLine({ personId, previous }: OnIdentity): string { return `Measured as "${personId}"${before}.`; } +/** A typed identifier refused, before it is asked for again. */ +export function printIdentityRule(output: CLIOutput): void { + output.warn(IDENTITY_RULE); +} + export function printOnResult(output: CLIOutput, result: OnResult): void { if (result.status === "refused") { output.error(REFUSALS[result.reason]); diff --git a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts index f363c2d2c..fee4c7f6a 100644 --- a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts @@ -420,6 +420,8 @@ describe("aidd telemetry on", () => { .mockResolvedValueOnce("Ada at Work"); expect(await run("on")).toBeNull(); expect(input).toHaveBeenCalledTimes(3); + expect(stderr().split("one non-empty line of at most 128 characters.")).toHaveLength(3); + expect(stderr()).not.toContain("Nothing was changed."); expect(turnOn).toHaveBeenCalledTimes(1); expect(turnOn).toHaveBeenCalledWith(process.cwd(), { set: "Ada at Work" }); }); diff --git a/plugins/aidd-telemetry/skills/00-init/actions/start.md b/plugins/aidd-telemetry/skills/00-init/actions/start.md index 947fbdf0a..cfe8a7897 100644 --- a/plugins/aidd-telemetry/skills/00-init/actions/start.md +++ b/plugins/aidd-telemetry/skills/00-init/actions/start.md @@ -25,7 +25,7 @@ Measurement is on for the project, or nothing changed and the reason is stated. - Not set: explain that a name only labels the person axis of their own report and stays on this machine, then ask the person to type the identifier they want, or to stay anonymous. Wait for the answer; never suggest or pick one for them. Say that `aidd telemetry on`, run in their own terminal, offers their git name instead. 4. **Turn on.** The yes was given in step 2, so the command's own confirmation is skipped: - identity already set: `aidd telemetry on --yes`; - - a name typed: `aidd telemetry on --yes --as ''`, with exactly the text typed, quoted for the shell; + - a name typed: `aidd telemetry on --yes --as ''`, with exactly the text typed, single-quoted for the shell, each `'` it holds written as `'\''` (`O'Brien` becomes `'O'\''Brien'`); - anonymous: `aidd telemetry on --yes --anonymous`. Staying anonymous is not stored: the next `on`, in another clone, asks again. - Outside a git repository, with a git config git cannot read, or with an identifier that is not one non-empty line of at most 128 characters, the command refuses: show its message and stop. 5. **Report.** Relay everything the command printed, the removals and the warnings included, in the person's words rather than pasted. From e795e0f6f9ce09d11683bfda6c917edc64088d35 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 06:21:08 +0200 Subject: [PATCH 10/10] docs(telemetry): close the identity-at-opt-in task with its review The review's two rounds, the gates rerun on the tree merged with the report filters, and the contract and plan marked done. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../2026_10_15_identity-at-opt-in/contract.md | 2 +- .../2026_10_15_identity-at-opt-in/evidence.md | 15 +++++ .../2026_10_15_identity-at-opt-in/plan.md | 2 +- .../2026_10_15_identity-at-opt-in/review.md | 61 +++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/review.md diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md index f31a95bb2..581f06c00 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: done issue: 1029 --- diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md index 760197849..b7acef74c 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/evidence.md @@ -262,3 +262,18 @@ Tests 1 failed | 45 passed (46) Green after: `tests/presentation`, `Tests 610 passed (610)`. Guard: with `printIdentityRule(output);` removed from the loop, `× asks again until the identifier typed is one it can keep`, `Tests 1 failed | 45 passed (46)`; reverted. + +## After merging origin/next (report filters) + +Merge `612f7019`. The three conflicts kept both sides (the `on` identity question beside the filter options, both public modules, both rows of the map). `report-display` test from `origin/next` expected `Selected: person = not set`, red on the merged tree as `AssertionError: expected 'Selected: person = anonymous' to be 'Selected: person = not set'`; aligned on `anonymous`. + +Every run of the built binary was sandboxed: `HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR` in fresh temp directories, `GIT_CONFIG_NOSYSTEM=1`. + +- `cd cli && pnpm test`: `Test Files 507 passed | 1 skipped (508)`, `Tests 6621 passed | 1 skipped (6622)`. +- `pnpm exec lefthook run pre-commit` (on the merge commit): every command green, `scripts-tests` included. +- `pnpm exec lefthook run pre-push`: the first run, beside the mutation run, failed 3 tests (`Tests 3 failed | 6618 passed`) and was not kept; rerun green, `✔️ cli-knip`, `✔️ cli-test`; the direct `pnpm test` above is green. +- `pnpm smoke`: `Bundle size: 692.8 KB / budget: 695 KB`, `PASS: 132 FAIL: 0 SKIP: 1`. +- `pnpm test:mutation:telemetry`: `Report: reports/mutation/telemetry/ (score 96.6, floor 96)`; no `Survived` or `NoCoverage` mutant on a line this branch changes against `origin/next`. +- `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js`, exit 0: `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository`. +- Same with `--attended`, exit 0: `… ; asked, answered by the hook, kept across /clear`. +- Identity set at `on`, then filtered (one seeded call): `on --yes --as "Ada Example"` printed `Measured as "Ada Example".`; `report --axis person --person "Ada Example"` printed `Selected: person = Ada Example` and the row `Ada Example 100 7 11 13 131`, JSON rows `[["value","Ada Example",1]]`; `report --axis person --absent person` printed `Selected: person = anonymous` and `No call in this period matches these filters.`, JSON rows `[]`. diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/plan.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/plan.md index d4de9e7ad..f3c5f1999 100644 --- a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/plan.md +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/plan.md @@ -1,6 +1,6 @@ --- objective: "Each person chooses, at opt-in, the name they are measured under, or to stay anonymous, and the report and status say which." -status: in-progress +status: done --- # Plan: Who is measured is chosen at opt-in diff --git a/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/review.md b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/review.md new file mode 100644 index 000000000..822b98544 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/review.md @@ -0,0 +1,61 @@ +# Review: Who is measured is chosen at opt-in + +- Rounds: 2 +- Started: 2026-10-15 + +## Round 1 · r-7c3e + +- Date: 2026-10-15 +- By: Baptiste LAFOURCADE +- Diff: `origin/next...1cbde1d2` +- Axes: code, functional, relevancy +- Verdict: approve +- Score: 7/7 met, 0 unmet, 0 unverified at runtime, 0 out of the diff + +### Criteria + +- [x] Every row of the Surface and Rules tables has a test, written first and seen failing — `evidence.md` red runs (phase 1: 6 failed, phase 2: 17 failed, e2e: 23 failed); rows green before the change (rule 4 refused-writes-nothing, rule 5 no git read) proven by recorded mutations; rule 6 by the existing `identity` tests; reran `cli/tests/contexts/telemetry/application/switch/telemetry-switch-use-cases.unit.test.ts`, `cli/tests/presentation/commands/telemetry-wiring.integration.test.ts:309-433`, `cli/tests/e2e/telemetry-lifecycle.e2e.test.ts:110-160` and 51 other files on 1cbde1d2: 54 files / 497 tests and 5 e2e files / 61 tests passed +- [x] A refused `on` writes neither consent nor identity, whatever the flags — `cli/src/contexts/telemetry/application/switch/telemetry-on-use-case.ts:91-105` (refusals before `readCloneConsent`, write after `grant`), tests `telemetry-switch-use-cases.unit.test.ts:459,471,481,493`; sandboxed binary: outside a repo with `--as Bob`, `--anonymous` with an identity set, blank typed id: consent ``, `identity.json` unchanged +- [x] No identity is ever written without `--as`, a typed id, or the proposal chosen — `telemetry-on-use-case.ts:105` (`chosen !== null` only from `{ set }`), `telemetry-wiring.integration.test.ts:385-433`; mutation of the write guard recorded in `evidence.md` (2 tests red); sandboxed pty run: proposal, typed, anonymous each wrote exactly what was chosen, anonymous wrote nothing +- [x] The envelope is byte-identical to before for every axis — no envelope test touched by the diff; `cli/src/presentation/display/telemetry/telemetry-report-display.ts:27` changes the text label only; `report-envelope.ts` not in the diff; `cli/tests/e2e/telemetry-report.e2e.test.ts:481-486` asserts the JSON row stays `absent`; sandboxed binary with one seeded call: text row `anonymous`, JSON row `{"kind":"absent","key":null}`, after `identity "Ada Example"` `{"kind":"value","key":"Ada Example"}` +- [x] Telemetry mutation at 96 or above, every changed-line mutant killed or explained — `evidence.md` records 96.2; `cli/reports/mutation/telemetry/mutation.json` (22:23) is newer than the last `cli/src` change (21:31), and intersecting its `Survived`/`NoCoverage` mutants with the diff's added lines in the 6 changed files it covers gives none; not rerun +- [x] `pnpm smoke`, `scripts/check-claude-tokens-end-to-end.cjs` both modes pass — rerun sandboxed on 1cbde1d2: smoke `PASS: 132 FAIL: 0 SKIP: 1`, check script `2 calls, 393 tokens, all on task demo, ticket T-1` (exit 0), `--attended` `… asked, answered by the hook, kept across /clear` +- [x] Docs and the `00-init` skill say what `on` asks and how to answer it non-interactively — `plugins/aidd-telemetry/README.md:28,36,54`, `plugins/aidd-telemetry/skills/00-init/actions/start.md:22-29`, `aidd_docs/product/usage-contract.md:284-298`, `cli/aidd_docs/memory/telemetry.md:60` + +### Findings + +- `aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/contract.md:39` : rule 5 says `AIDD_TELEMETRY=0` "refuses as today", but `on` never refused under it (no `AIDD_TELEMETRY` in `application/switch/` on `origin/next`), and the code now asserts the opposite (`cli/tests/e2e/telemetry-lifecycle.e2e.test.ts:154`); rule 6 says `identity` is unchanged while its output now reads `anonymous` (`cli/src/presentation/display/telemetry-display.ts:163,167`). The contract is the authority and its decisions were the user's; neither deviation is recorded in it or in `evidence.md` → amend rules 5 and 6 to what ships (no refusal, no git read under `AIDD_TELEMETRY=0`; `identity` wording aligned on `anonymous`) and get the user's acknowledgement. The code meets rule 5 as literally written (same outcome as before, git not asked) and rule 6's wording follows decision 1, so the flaw is the contract's wording, not the work — 🟢 minor, fit +- `plugins/aidd-telemetry/skills/00-init/actions/start.md:28` : the probe at `SKILL.md:35` (unchanged line) `aidd telemetry --help` passes on a CLI too old for `--as`/`--anonymous` (CLI 5.4.0 has `telemetry on`), so this plugin on that CLI fails at `on --yes --as` with Commander's `unknown option '--as'` and, per the skill's own last rule, stops without telling the person to update `@ai-driven-dev/cli` → probe `aidd telemetry on --help` and require `--anonymous` in it before any action. Pre-existing: on `origin/next` the skill ran bare `on --yes`, which CLI 5.4.0 runs as its old git-tracked switch, so this branch turns a silent wrong write into a loud refusal — 🟢 minor, fit +- `cli/src/presentation/commands/telemetry.ts:87` : the refusal text is a copy of `REFUSALS["invalid-identity"]` in `cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts:25` → print it through `printOnResult(output, { status: "refused", reason: "invalid-identity" })` or one shared constant — 🟢 minor, rot +- `cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts:25` : a blank `--as " "` or a blank typed answer gets "An identity is one line of at most 128 characters", which a blank line satisfies → say "one non-empty line of at most 128 characters" (also the prompt at `cli/src/presentation/commands/telemetry.ts:66`) — 🟢 minor, standards +- `cli/src/presentation/commands/telemetry.ts:66` : an invalid typed identifier is only caught by the use case, after the person confirmed the opt-in and answered the question, so the whole `on` exits 1 and must be rerun → re-ask until `personIdOf` accepts the answer (a loop here, no port change), keeping the use case check as the guard — 🟢 minor, error-handling +- `plugins/aidd-telemetry/skills/00-init/actions/start.md:25` : the skill derives its own proposal with `git config user.name`, without the CLI's filters (rejected by `personIdOf`, none under `AIDD_TELEMETRY=0`), so it can offer a name the CLI would refuse or never offer; and line 28 does not say to quote `` for the shell → offer only a name of one line and at most 128 characters, and run `--as ''` quoted — 🟢 minor, fit +- `plugins/aidd-telemetry/skills/00-init/SKILL.md:39` : the rewritten rule has the agent run `git config user.name` itself, so the name enters the session transcript on disk and the model's context before the person chooses, while `on` only shows it on the terminal. The contract's Docs section sanctions offering it, so this is the user's call → keep, or let the CLI resolve the proposal itself (e.g. `on --yes --as-git-name`) so the name never enters the transcript — 🟢 minor, fit +- `cli/src/contexts/telemetry/application/identity/propose-identity-use-case.ts:12` : under `AIDD_TELEMETRY=0` the proposal is withheld but the question is still asked and `on` succeeds, so the environment's refusal changes only which choices appear → keep it (conservative), but state it in the amended contract rule 5 — 🟢 minor, fit +- `plugins/aidd-telemetry/skills/00-init/actions/start.md:44` : the generic "The person says yes" row overlaps the two new rows at lines 42-43 (both assert which `on` ran) → keep only its consent and `.aidd/config.json` checks — 🟢 minor, rot + +## Round 2 · r-b41d + +- Date: 2026-10-16 +- By: Baptiste LAFOURCADE +- Diff: `origin/next...a770739d` +- Axes: code, functional, relevancy +- Verdict: approve +- Score: 7/7 met, 0 unmet, 0 unverified at runtime, 0 out of the diff + +### Criteria + +- [x] Every row of the Surface and Rules tables has a test, written first and seen failing — `evidence.md` "Review round 1 fixes" red run (5 failed) and guard (removing the re-ask check fails `asks again until the identifier typed is one it can keep`); amended rules 5 and 6 (`contract.md:39-40`) covered by `cli/tests/e2e/telemetry-lifecycle.e2e.test.ts:154` and `cli/tests/presentation/display/telemetry-display.unit.test.ts:38`; reran sandboxed on a770739d: `tests/contexts/telemetry`, `tests/presentation`, `tests/architecture`, `tests/golden` 140 files / 1931 tests passed, 5 telemetry e2e files / 61 tests passed; `pnpm typecheck` exit 0, biome clean on the changed files +- [x] A refused `on` writes neither consent nor identity, whatever the flags — unchanged `telemetry-on-use-case.ts`; sandboxed binary: `on --yes --as " "` exits 1 with the `printOnResult` refusal, nothing written +- [x] No identity is ever written without `--as`, a typed id, or the proposal chosen — `cli/src/presentation/commands/telemetry.ts:66-69` returns only a typed value `personIdOf` accepts; sandboxed pty run: blank typed, asked again, `Ada at Work` typed, `identity.json` = `{"person_id":"Ada at Work"}` +- [x] The envelope is byte-identical to before for every axis — round 2 touches no report or envelope file +- [x] Telemetry mutation at 96 or above, every changed-line mutant killed or explained — rerun on a770739d, sandboxed: `Report: reports/mutation/telemetry/ (score 96.2, floor 96)`; no `Survived`/`NoCoverage` mutant on a line the branch added +- [x] `pnpm smoke`, `scripts/check-claude-tokens-end-to-end.cjs` both modes pass — `evidence.md` "Review round 1 fixes" gates on the final code; round 2 changes no path either script exercises beyond the refusal text, which smoke does not assert +- [x] Docs and the `00-init` skill say what `on` asks and how to answer it non-interactively — `plugins/aidd-telemetry/skills/00-init/actions/start.md:25-30` (type an identifier or stay anonymous, `--as ''` quoted, terminal `on` offers the git name), `SKILL.md:35` probe requires `--anonymous` in `aidd telemetry on --help` (sandboxed binary: present; CLI 5.4.0's `on` has only `--yes`), `contract.md:63` Docs section matches + +### Findings + +- `cli/src/presentation/commands/telemetry.ts:66-69` : a refused typed identifier is asked again with the same prompt and no word on why, so a person who typed 129 characters sees the question repeat silently → print the `invalid-identity` refusal (without "Nothing was changed.") before re-asking — 🟢 minor, error-handling +- `plugins/aidd-telemetry/skills/00-init/actions/start.md:28` : `--as ''` single-quoted breaks on an identifier holding `'` (`O'Brien`) → say "quoted for the shell, escaping any quote it holds" — 🟢 minor, fit +- `aidd_docs/tasks/2026_10/2026_10_15_identity-at-opt-in/review.md` "Deviations recorded" : a section outside the shape `review-validator.yml` declares (rounds hold only Criteria and Findings), restating what `contract.md:39-40,63` now holds → drop it, the contract is the canonical record — 🟢 minor, rot +- All three fixed in `39afc591`.