diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 8b67f78ea..54a4207c2 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -277,6 +277,7 @@ stateDiagram-v2 - `.aidd/config.json` is never consent: not `telemetry: {enabled: true, version: 2}`, not the old bare `enabled: true`. Not read for consent. - Unreadable git config: `unreadable`, grants nothing, never rewritten. - A clone that never opted in: nothing stored, asked or blocked. +- Key `off`, from `off` or by hand: `status` and `task` say measurement was turned off in this clone and that `aidd telemetry on` measures it again; a damaged log or an unidentified clone reads unreadable instead, since `on` would refuse. Any other key that grants nothing reads as never opted in. Ingest still counts its calls under `no-consent`. **`on` also:** removes the old `telemetry` block from `.aidd/config.json` byte-for-byte otherwise, deleting the file only if that block was all (says the change may need committing); removes an earlier measurement's leftovers in the repository; records its clone for `forget`; warns when Claude Code's transcript retention is short; ends with the next step, declaring a task, which the hooks ask for in Claude Code. Does not reset offsets. diff --git a/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/contract.md b/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/contract.md new file mode 100644 index 000000000..97d562a8a --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/contract.md @@ -0,0 +1,57 @@ +--- +status: done +issue: 1035 +--- + +# Status after `off` says measurement was turned off + +## Need + +After `aidd telemetry on` then `aidd telemetry off`, `aidd telemetry status` prints `Not measured: this clone has not run \`aidd telemetry on\`.` (#1035). That is false: the clone ran `on`. The person is told to do what they already did, and is not told that they turned measurement off themselves. `aidd telemetry task` in that clone says the same falsehood: `This clone has not opted in to measurement`. + +`off` leaves a trace that tells the two apart: it writes `aidd.telemetry = off` (`CONSENT_WITHDRAWN`) in the clone's git config, and only when the clone's key granted consent. A clone that never ran `on` has no key, or a key that grants nothing and is not `off`. + +## Decision + +| # | Decision | Why | +| --- | --- | --- | +| 1 | A new refusal reason, `turned-off`, beside `no-consent`, read from the key `off` | The refusal reason is printed as text only: `status` and `task` have no JSON output, and the report's refused envelope carries no reason. No contract changes, and the exhaustive word tables force both commands to say something true | +| 2 | The domain says what the key `off` means, in a function of its own beside `CONSENT_WITHDRAWN`; `consentOf` keeps its three answers | `consentOf` is pinned by the fixture the hooks share (`scripts/__tests__/fixtures/telemetry-bindings`, `off` reads `absent`), and the hooks only ask whether a key grants. The application compares no strings | +| 3 | `NotStoredReason` and the report envelope are not touched | `not_stored.no-consent` is part of `version: 1`, and the issue is about status | + +## Surface + +| Command | After `on` then `off` | Clone that never ran `on` | +| --- | --- | --- | +| `aidd telemetry status` | `Not measured: measurement was turned off in this clone. Run \`aidd telemetry on\` to measure it again.` exit 0 | unchanged: `Not measured: this clone has not run \`aidd telemetry on\`.` | +| `aidd telemetry task ` | `Measurement was turned off in this clone, so no task was declared. Run \`aidd telemetry on\` here to measure it again.` exit 1 | unchanged | + +## Rules + +| # | Rule | +| --- | --- | +| 1 | A key of exactly `off` is `turned-off`, whether `off` wrote it or a person did by hand: either way measurement was turned off in this clone, and `on` turns it back on | +| 2 | Any other key that grants nothing (absent, `2`, `true`, `{}`) stays `no-consent` | +| 3 | Precedence is unchanged: `AIDD_TELEMETRY=0`, outside a repository, unidentified repository and an unreadable git config come first. `turned-off` is decided only once the consent log is read and the clone identified, where `on` would not refuse it: a key `off` with a damaged log, or a clone with no identity, reads `unreadable-consent`, so status never advises an `on` that would fail. An absent key still reads `no-consent` before the log, as today | +| 4 | `on` after `off` measures again and status says so, as today | + +## Unchanged + +The consent key format, the consent log, `off` itself, ingest and its `not_stored` reasons (`no-consent` still counts calls made while a clone was off), the report envelope, the hooks. A key `2:` that names a closed interval, the status-side counterpart of `consent-closed`, still reads `no-consent`: the `on` then `off` flow never leaves one. + +## Docs + +The usage contract states what `status` says after `off`. No doc, skill or hook quotes the old message (grep in `evidence.md`). + +## Acceptance + +| # | Criterion | Proof | +| --- | --- | --- | +| 1 | Each Surface row and Rules 1 to 3 have a test, written first and seen failing | tests, red run in `evidence.md` | +| 2 | A never-on clone keeps today's status and task text, byte for byte | existing display tests unchanged | +| 3 | Each guard is proven by a mutation that turns it red | `evidence.md` | +| 4 | `cd cli && pnpm test`, lefthook pre-commit and pre-push green | `evidence.md` | +| 5 | `pnpm smoke` passes and the bundle stays within 695 KB | `evidence.md` | +| 6 | `scripts/check-claude-tokens-end-to-end.cjs` passes, plain and `--attended` | `evidence.md` | +| 7 | Telemetry mutation at 96 or above, every mutant on a changed line killed or explained | stryker | +| 8 | Sandboxed repro: after `off`, status is wrong before and right after; a never-on and an on clone unchanged | `evidence.md` | diff --git a/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/evidence.md b/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/evidence.md new file mode 100644 index 000000000..15bf57920 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/evidence.md @@ -0,0 +1,198 @@ +# Evidence + +## Red runs + +`pnpm exec vitest run tests/contexts/telemetry/domain/telemetry-consent.unit.test.ts tests/contexts/telemetry/application/telemetry-status-use-case.unit.test.ts tests/contexts/telemetry/application/declare-task-use-case.unit.test.ts tests/presentation/display/telemetry-display.unit.test.ts`, before `turnedOff`, `turned-off` and its words exist: + +```txt +× is one whose key is off, as `aidd telemetry off` writes it +× is not one whose key is null / "2" / "2:t" / "true" / "{}" / "OFF" / " off" / "off " / "" +× is not one whose git config cannot be read + TypeError: turnedOff is not a function +× explains each refusal + AssertionError: expected undefined to be 'Measurement was turned off in this cl…' +× explains a refusal + AssertionError: expected [ undefined ] to deeply equal [ Array(1) ] +× says a clone turned off was turned off, even with a log that cannot be read + AssertionError: expected { status: 'refused', …(1) } to deeply equal { status: 'refused', …(1) } +× refuses a clone turned off as turned off, and writes nothing + AssertionError: expected { status: 'refused', …(1) } to deeply equal { status: 'refused', …(1) } +Tests 15 failed | 57 passed (72) +``` + +Green before the change, by construction: "never says a clone with no key, or a key that grants nothing, was turned off" (today nothing is ever `turned-off`). Proven a guard by M1, M2 and M4 below. + +`pnpm exec vitest run tests/e2e/telemetry-lifecycle.e2e.test.ts -t "turned off"`: + +```txt +× says, after on then off, that measurement was turned off and how to turn it back on +AssertionError: expected 'Not measured: this clone has not run …' to contain 'Not measured: measurement was turned …' +Tests 1 failed | 11 skipped (12) +``` + +The never-on assertions before it passed: the clone that never ran `on` keeps today's words. + +## Existing tests + +No existing `no-consent` assertion seeds the key `off`, so none flipped. `consentOf` is unchanged: the fixture the hooks share (`scripts/__tests__/fixtures/telemetry-bindings/expected.json`, `"off": "absent"`) still holds. The `no-consent` declare refusal is now pinned to its exact words instead of `toContain`. + +## Mutations + +Each applied by hand to the source, the four unit files above run, then reverted: + +```txt +## M1 turnedOff ignores the value: 14 red + × is not one whose key is null + × is not one whose key is "2" + × is not one whose key is "2:t" + × is not one whose key is "true" + × is not one whose key is "{}" + × is not one whose key is "OFF" + × is not one whose key is " off" + × is not one whose key is "off " + × is not one whose key is "" + × refuses like a declaration does + × never says a clone with no key, or a key that grants nothing, was turned off + × refuses a project that has not opted in, and writes nothing + × refuses a clone with no consent, and one whose git config cannot be read, differently + × refuses a key that names no interval open for the clone, and a damaged log +## M2 turnedOff ignores the kind: 15 red + × is not one whose key is null + × is not one whose key is "2" + × is not one whose key is "2:t" + × is not one whose key is "true" + × is not one whose key is "{}" + × is not one whose key is "OFF" + × is not one whose key is " off" + × is not one whose key is "off " + × is not one whose key is "" + × is not one whose git config cannot be read + × refuses like a declaration does + × never says a clone with no key, or a key that grants nothing, was turned off + × refuses a project that has not opted in, and writes nothing + × refuses a clone with no consent, and one whose git config cannot be read, differently + × refuses a key that names no interval open for the clone, and a damaged log +## M3 off key reads no-consent: 2 red + × says a clone turned off was turned off, even with a log that cannot be read + × refuses a clone turned off as turned off, and writes nothing +## M4 any absent key reads turned-off: 5 red + × refuses like a declaration does + × never says a clone with no key, or a key that grants nothing, was turned off + × refuses a project that has not opted in, and writes nothing + × refuses a clone with no consent, and one whose git config cannot be read, differently + × refuses a key that names no interval open for the clone, and a damaged log +## M5 turned-off decided after the log: 2 red + × says a clone turned off was turned off, even with a log that cannot be read + × refuses a key that names no interval open for the clone, and a damaged log +## M6 status words say has not run: 1 red + × explains a refusal +## M7 task words say not opted in: 1 red + × explains each refusal +``` + +## Gates + +| Gate | Decisive line | +| --- | --- | +| `cd cli && pnpm test` | `Tests 6636 passed \| 1 skipped (6637)`, exit 0 | +| `pnpm exec lefthook run pre-commit` (on commit 519ec045) | `✔️ cli-architecture`, `cli-biome`, `cli-type-honesty`, `cli-typecheck`, `markdown-links`, `scripts-tests`, `commitlint`: all green | +| `pnpm exec lefthook run pre-push` | first run: `golden-baseline.e2e.test.ts > snapshot is deterministic` timed out at 60000 ms (machine shared with another agent; the same test passed in `pnpm test` above, and nothing here touches it). Second run: `Tests 6636 passed \| 1 skipped (6637)`, `✔️ cli-knip`, `✔️ cli-test`, exit 0 | +| `pnpm smoke` (sandboxed) | `PASS: 132 FAIL: 0 SKIP: 1 · coverage 100%`, exit 0 | +| Bundle budget | `Bundle size: 693.1 KB / budget: 695 KB` (692.8 KB before) | +| `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` (sandboxed) | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository`, exit 0 | +| same, `--attended` | `…; asked, answered by the hook, kept across /clear`, exit 0 | +| `pnpm test:mutation:telemetry` | `score 96.3, floor 96` | + +### Mutants on changed lines + +`consented-repositories.ts` lines 45 to 46: 9 mutants, all killed. `telemetry-consent.ts` lines 49 to 50: 9 mutants, 8 killed. The survivor is equivalent: `reading.kind === "value"` replaced by `true` leaves `reading.value === CONSENT_WITHDRAWN`, and an unreadable reading has no `value`, so `undefined === "off"` is still `false`. The kind test is there for the type checker's narrowing only. `telemetry-display.ts` is outside the telemetry mutation scope; its two new strings are pinned by exact `toBe`/`toEqual` (M6, M7). + +## Docs grep + +`git grep -n "Not measured\|has not opted in\|has not run" -- ':!aidd_docs/tasks' ':!cli/aidd_docs/tasks'`: the old words appear only in `telemetry-display.ts` and its tests, and in test names. No skill, hook, README or memory quotes them. The usage contract's consent section gains one line on the key `off`. + +## Sandboxed repro + +Built `cli/dist/cli.js`, three fresh clones in a `mktemp -d` sandbox (HOME, XDG_CONFIG_HOME, AIDD_USER_CONFIG_DIR, AIDD_TELEMETRY_DIR, CLAUDE_CONFIG_DIR inside it, `GIT_CONFIG_NOSYSTEM=1`): `never` never runs `on`, `on` runs `on --yes --anonymous`, `off` runs `on --yes --anonymous` then `off`. + +Before: + +```txt +-- off: Measurement is off for this clone. What was measured stays until `aidd telemetry forget --yes`. +== never: status +Not measured: this clone has not run `aidd telemetry on`. +== on: status +This clone is measured since 2026-10-16. +Nothing is bound: the default branch master, which is never bound. Declare with `aidd telemetry task `. +Measured anonymously. To be named, run `aidd telemetry identity `. +== off: status +Not measured: this clone has not run `aidd telemetry on`. +== off: task +Error: This clone has not opted in to measurement, so no task was declared. Run `aidd telemetry on` here first. +exit=1 +== off: git key = off +``` + +After: + +```txt +-- off: Measurement is off for this clone. What was measured stays until `aidd telemetry forget --yes`. +== never: status +Not measured: this clone has not run `aidd telemetry on`. +== on: status +This clone is measured since 2026-10-16. +Nothing is bound: the default branch master, which is never bound. Declare with `aidd telemetry task `. +Measured anonymously. To be named, run `aidd telemetry identity `. +== off: status +Not measured: measurement was turned off in this clone. Run `aidd telemetry on` to measure it again. +== off: task +Error: Measurement was turned off in this clone, so no task was declared. Run `aidd telemetry on` here to measure it again. +exit=1 +== off: git key = off +``` + +## Review round 1 fixes + +Both minor findings of `review.md` round 1, fixed in `f0f30ac3`. Contract Rule 3, the plan and the usage contract now say a key `off` reads `turned-off` only once the consent log is read and the clone identified; otherwise `unreadable-consent`, because `on` would refuse there. + +### Red run + +`pnpm exec vitest run tests/contexts/telemetry/application/telemetry-status-use-case.unit.test.ts tests/contexts/telemetry/application/declare-task-use-case.unit.test.ts`, before the change: + +```txt +× says a clone turned off cannot be read, not to run on, when on would refuse +AssertionError: expected { status: 'refused', …(1) } to deeply equal { status: 'refused', …(1) } +Tests 1 failed | 27 passed (28) +``` + +`"{}"` in the never-opted-in loop is green by construction: it was already `no-consent`. G4 below proves it a guard. + +### Guard breaks + +Each applied to the source, the two files above run, then reverted: + +```txt +## G1 turned-off decided before the log again: 1 red + × says a clone turned off cannot be read, not to run on, when on would refuse +## G2 an off key reads no-consent: 3 red + × says a clone turned off was turned off + × says a clone turned off cannot be read, not to run on, when on would refuse + × refuses a clone turned off as turned off, and writes nothing +## G3 the turned-off answer is skipped: 2 red + × says a clone turned off was turned off + × refuses a clone turned off as turned off, and writes nothing +## G4 a key {} reads turned off: 2 red + × refuses like a declaration does + × never says a clone with no key, or a key that grants nothing, was turned off +``` + +### Gates + +| Gate | Decisive line | +| --- | --- | +| `cd cli && pnpm test` | `Tests 6637 passed \| 1 skipped (6638)`, exit 0 | +| lefthook pre-commit (on `f0f30ac3`) | `cli-architecture`, `cli-biome`, `cli-type-honesty`, `cli-typecheck`, `markdown-links`, `scripts-tests`, `commitlint`: all `✔️` | +| lefthook pre-push | `Tests 6637 passed \| 1 skipped (6638)`, `✔️ cli-knip`, `✔️ cli-test`, exit 0 | +| `pnpm test:mutation:telemetry` | `score 96.3, floor 96` | + +Mutants on changed lines: `consented-repositories.ts` 15 of 15 killed. `telemetry-consent.ts` lines 49 to 50: 8 of 9 killed. The survivor is the same equivalent mutant as before (`reading.kind === "value"` replaced by `true`). diff --git a/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/plan.md b/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/plan.md new file mode 100644 index 000000000..6b295fac7 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/plan.md @@ -0,0 +1,32 @@ +--- +objective: "After `off`, status and task say measurement was turned off in this clone and how to turn it back on; a clone that never ran `on` keeps today's words." +status: done +--- + +# Plan: Status after `off` says measurement was turned off + +## Overview + +| Field | Value | +| ---------- | ----------------------- | +| **Goal** | a `turned-off` refusal, read from the key `off`, worded for `status` and `task` | +| **Source** | [`contract.md`](./contract.md), issue #1035 | + +One phase: the change is a domain variant, one branch, two words and a docs line. + +## Steps + +| # | Step | Files | +| --- | --- | --- | +| 1 | Tests first, seen red: the domain reads `off`, and only `off`, as turned off; status and declare refuse `turned-off` for `off` and keep `no-consent` for absent, `2`, `true`, `{}`; a damaged log or an unidentified clone beats `turned-off`; both words exact; the lifecycle e2e runs `on`, `off`, `status`, `task` | `telemetry-consent` unit test, `telemetry-status-use-case`, `declare-task-use-case`, `telemetry-display` unit tests, `telemetry-lifecycle.e2e.test.ts` | +| 2 | `telemetry-consent.ts` says whether a key was turned off; `RefusalReason` gains `turned-off`; `ConsentedRepositories` maps it; the two word tables say it | `domain/telemetry-consent.ts`, `application/consented-repositories.ts`, `presentation/display/telemetry-display.ts` | +| 3 | Each guard proven by a mutation, reverted | `evidence.md` | +| 4 | Usage contract: what status says after `off` | `aidd_docs/product/usage-contract.md` | +| 5 | Gates and the sandboxed repro | `evidence.md` | + +## Decisions + +| Decision | Why | +| --- | --- | +| A domain function beside `CONSENT_WITHDRAWN`, not a `Consent` variant | `consentOf` is pinned to `absent` for `off` by the fixture the hooks share; the grammar still stays in the domain | +| `turned-off` is decided after the consent log is read | status never advises an `on` that would refuse (contract rule 3, review round 1) | diff --git a/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/review.md b/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/review.md new file mode 100644 index 000000000..2a23673dc --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_status-after-off/review.md @@ -0,0 +1,58 @@ +# Review: Status after `off` says measurement was turned off + +- Rounds: 1 +- Started: 2026-10-16 + +## Round 1 · r-3f9a + +- Date: 2026-10-16 +- By: Baptiste LAFOURCADE +- Diff: `origin/next...24e74efc` +- Axes: code, functional, relevancy +- Verdict: approve +- Score: 8/8 met, 0 unmet, 0 unverified at runtime, 0 out of the diff + +### Criteria + +- [x] Each Surface row and Rules 1 to 3 have a test, written first and seen failing. Evidence: the red runs in `evidence.md` (15 unit failures, then 1 e2e failure). Tests and fix land in one commit (`519ec045`), so the red run rests on `evidence.md` alone. + - Surface: `cli/tests/e2e/telemetry-lifecycle.e2e.test.ts:154-171`. + - Rule 1: `cli/tests/contexts/telemetry/domain/telemetry-consent.unit.test.ts:46-61` and `cli/tests/contexts/telemetry/application/declare-task-use-case.unit.test.ts:224`. + - Rule 2: `telemetry-status-use-case.unit.test.ts:184-193`. + - Rule 3: `telemetry-status-use-case.unit.test.ts:171-182`. + - Rerun on `24e74efc`: `tests/contexts/telemetry` and `tests/presentation` passed 111 files / 1828 tests, and `telemetry-lifecycle.e2e.test.ts` passed 12 tests. +- [x] A never-on clone keeps today's status and task text, byte for byte. + - The existing `no-consent` words are unchanged in `cli/src/presentation/display/telemetry-display.ts:70,82` and are now pinned by an exact `toBe` at `telemetry-display.unit.test.ts:217`. + - In a sandboxed run of the binary, the never-on status and task output is identical before and after the change. +- [x] Each guard is proven by a mutation that turns it red. `evidence.md` M1 to M7, plus three mutations of my own, each reverted with `cp` (the worktree `src` is clean afterwards): + - A: `turnedOff` matching trimmed and lowercased text. 4 red, among them `is not one whose key is "OFF"` and `never says a clone with no key, or a key that grants nothing, was turned off`. + - B: `consented-repositories.ts:46` always giving `no-consent`. 2 red, `says a clone turned off was turned off, even with a log that cannot be read` and `refuses a clone turned off as turned off, and writes nothing`. + - C: the `NOT_MEASURED["turned-off"]` text replaced with the old words. 1 red, `explains a refusal`. +- [x] `cd cli && pnpm test` and lefthook pre-commit and pre-push are green. + - `evidence.md` records 6636 passed and every hook green. + - I reran `pnpm typecheck` (exit 0) and `node --test scripts/__tests__/*.test.js` (452 pass, 0 fail). That includes the shared hooks fixture `"off": "absent"`, which `consentOf` still satisfies. +- [x] `pnpm smoke` passes and the bundle stays within 695 KB. + - I rebuilt the bundle on `24e74efc`: `Bundle size: 693.1 KB / budget: 695 KB`. + - Smoke is from `evidence.md` (`PASS: 132 FAIL: 0`). The diff touches no path that smoke asserts beyond two new strings. +- [x] `scripts/check-claude-tokens-end-to-end.cjs` passes, plain and `--attended`. + - I reran the plain mode sandboxed: `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository`, exit 0. + - The `--attended` result is from `evidence.md`. +- [x] Telemetry mutation is at 96 or above, and every mutant on a changed line is killed or explained. + - `cli/reports/mutation/telemetry/mutation.json` (10:51) is newer than `519ec045` (09:19). + - Every mutant on `consented-repositories.ts:44-51` is killed. + - `telemetry-consent.ts:50` has one survivor, the `ConditionalExpression` on `reading.kind === "value"`. It is equivalent, because an unreadable reading has no `value`. + - Not rerun. +- [x] Sandboxed repro: after `off`, status is wrong before the change and right after it, and a never-on clone and an on clone are unchanged. + - Setup: two builds, one from the `origin/next` sources of the 3 changed files and one from HEAD. Each ran with HOME, XDG_CONFIG_HOME, AIDD_USER_CONFIG_DIR, AIDD_TELEMETRY_DIR and CLAUDE_CONFIG_DIR inside a fresh `mktemp -d`, and with `GIT_CONFIG_NOSYSTEM=1`. + - On the before build, the off clone prints `Not measured: this clone has not run \`aidd telemetry on\`.` + - On the after build, it prints `Not measured: measurement was turned off in this clone. Run \`aidd telemetry on\` to measure it again.`, and `task` exits 1 with the new words. + - Unchanged on both builds: the never-on clone, the on clone, `AIDD_TELEMETRY=0` (`Not measured: AIDD_TELEMETRY=0.`), a key `true` set by hand (`no-consent`), `on` after `off` (`measured since`), and `on`, then `off`, then `forget --yes` (the key is unset, so `no-consent`). + - A key `off` set by hand reads `turned-off`, as Rule 1 says. + +### Findings + +- `cli/src/contexts/telemetry/application/consented-repositories.ts:45-46`: a key `off` with a damaged consent log gets status `Run \`aidd telemetry on\` to measure it again`. In the sandboxed run, that `on` then exits 1 with `ledger/consents.jsonl holds a line that is not a consent event`. So the advice fails, although the next command does name the remedy (`forget --yes`). + - Contract Rule 3 chose this on purpose, for parity with an absent key reading `no-consent` today. + - Fix: keep it, or decide `turned-off` only once `ConsentLog.load(this.history)` is not damaged and fall through to `unreadable-consent` otherwise. That needs Rule 3 and the test at `telemetry-status-use-case.unit.test.ts:171` to be amended. + - 🟢 minor, fit. +- `cli/tests/contexts/telemetry/application/telemetry-status-use-case.unit.test.ts:188`: contract Rule 2 lists `{}` among the keys that stay `no-consent` (`contract.md:34`), and plan step 1 names it too, but this loop covers only `"2", "true", "OFF"`. The domain test covers `{}`, the application test does not. Fix: add `"{}"` to the loop. 🟢 minor, tests. +- Both findings fixed in `f0f30ac3`: a key `off` reads `turned-off` only once the log is read and the clone identified, else `unreadable-consent`; `"{}"` added to the never-opted-in loop. diff --git a/cli/src/contexts/telemetry/application/consented-repositories.ts b/cli/src/contexts/telemetry/application/consented-repositories.ts index fe1041cd1..cb76c1cee 100644 --- a/cli/src/contexts/telemetry/application/consented-repositories.ts +++ b/cli/src/contexts/telemetry/application/consented-repositories.ts @@ -2,7 +2,7 @@ import type { ConsentHistory } from "../domain/ports/consent-history.js"; import type { ConsentSource } from "../domain/ports/consent-source.js"; import type { RepositoryLocator } from "../domain/ports/repository-locator.js"; import { repositoryIdOf } from "../domain/repository-identity.js"; -import { consentOf, tokenOfKey } from "../domain/telemetry-consent.js"; +import { consentOf, tokenOfKey, turnedOff } from "../domain/telemetry-consent.js"; import { ConsentLog } from "./consent-log.js"; export type RefusalReason = @@ -10,6 +10,7 @@ export type RefusalReason = | "outside-repository" | "unidentified-repository" | "no-consent" + | "turned-off" | "unreadable-consent"; export type ConsentedRepository = @@ -41,12 +42,15 @@ export class ConsentedRepositories { const reading = await this.consents.read(located.root); const consent = consentOf(reading); if (consent === "unreadable") return { status: "refused", reason: "unreadable-consent" }; - if (consent === "absent") return { status: "refused", reason: "no-consent" }; + const off = turnedOff(reading); + if (consent === "absent" && !off) return { status: "refused", reason: "no-consent" }; const log = await ConsentLog.load(this.history); - // A log that cannot be read, or a clone the platform cannot identify, measures nothing. + // A log that cannot be read, or a clone the platform cannot identify, measures nothing. A + // clone turned off is told so only past this point, where `on` would not refuse it. if (log.damaged || located.clone === null) { return { status: "refused", reason: "unreadable-consent" }; } + if (off) return { status: "refused", reason: "turned-off" }; const token = tokenOfKey(reading.kind === "value" ? reading.value : null); const named = log.openFor(located.clone).find((interval) => interval.token === token); if (named === undefined) return { status: "refused", reason: "no-consent" }; diff --git a/cli/src/contexts/telemetry/domain/telemetry-consent.ts b/cli/src/contexts/telemetry/domain/telemetry-consent.ts index 232865f60..570308f4a 100644 --- a/cli/src/contexts/telemetry/domain/telemetry-consent.ts +++ b/cli/src/contexts/telemetry/domain/telemetry-consent.ts @@ -44,6 +44,12 @@ export function consentOf(reading: ConsentReading): Consent { return tokenOfKey(reading.value) === null ? "absent" : "granted"; } +/** Whether the clone's key says measurement was turned off in it: `off` writes `off`, and a + * person who writes it by hand turned it off too. Either way `on` measures it again. */ +export function turnedOff(reading: ConsentReading): boolean { + return reading.kind === "value" && reading.value === CONSENT_WITHDRAWN; +} + /** `AIDD_TELEMETRY` set to exactly `0` refuses measurement, whatever a clone granted. Reading * the environment is the composition root's job; this is only what its value means. */ export function refusedByEnvironment(value: string | undefined): boolean { diff --git a/cli/src/presentation/display/telemetry-display.ts b/cli/src/presentation/display/telemetry-display.ts index fd46f1ae1..528b60237 100644 --- a/cli/src/presentation/display/telemetry-display.ts +++ b/cli/src/presentation/display/telemetry-display.ts @@ -69,6 +69,8 @@ const REFUSALS: Readonly> = { "This repository has no remote and no commit yet, so nothing can name it. Commit once, then declare again.", "no-consent": "This clone has not opted in to measurement, so no task was declared. Run `aidd telemetry on` here first.", + "turned-off": + "Measurement was turned off in this clone, so no task was declared. Run `aidd telemetry on` here to measure it again.", "unreadable-consent": "This clone's git config cannot be read, so no task was declared. Fix it, then declare again.", }; @@ -78,6 +80,8 @@ const NOT_MEASURED: Readonly> = { "outside-repository": "Not measured: not inside a git repository.", "unidentified-repository": "Not measured: this repository has no remote and no commit yet.", "no-consent": "Not measured: this clone has not run `aidd telemetry on`.", + "turned-off": + "Not measured: measurement was turned off in this clone. Run `aidd telemetry on` to measure it again.", "unreadable-consent": "Not measured: this clone's git config or the consent log cannot be read.", }; diff --git a/cli/tests/contexts/telemetry/application/declare-task-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/declare-task-use-case.unit.test.ts index 0b9fcbe0c..b69ef8293 100644 --- a/cli/tests/contexts/telemetry/application/declare-task-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/declare-task-use-case.unit.test.ts @@ -221,6 +221,18 @@ describe("refusing to declare", () => { expect(events).toEqual([]); }); + it("refuses a clone turned off as turned off, and writes nothing", async () => { + const { useCase, consents, sessions, branches, events } = setup(); + consents.values.set(CWD, "off"); + expect(await useCase.execute({ cwd: CWD, request: TASK, by: "command" })).toEqual({ + status: "refused", + reason: "turned-off", + }); + expect(sessions.lines).toEqual([]); + expect(branches.declared).toEqual([]); + expect(events).toEqual([]); + }); + it("refuses a clone with no consent, and one whose git config cannot be read, differently", async () => { const none = setup(); none.consents.values.delete(CWD); 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 8237e47e8..9b10d2179 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 @@ -55,7 +55,7 @@ function setup(sessionId: string | null = "sess-1234567890", refusedByEnvironmen { refusedByEnvironment, sessionId, now: () => new Date("2026-10-09T12:00:00.000Z") }, identities ); - return { useCase, consents, sessions, branches, source, identities }; + return { useCase, locator, consents, history, sessions, branches, source, identities }; } const branchBinding = { @@ -167,4 +167,38 @@ describe("showing what the work is bound to", () => { reason: "environment", }); }); + + it("says a clone turned off was turned off", async () => { + const off = setup(); + off.consents.values.set(CWD, "off"); + expect(await off.useCase.execute(CWD)).toEqual({ status: "refused", reason: "turned-off" }); + }); + + it("says a clone turned off cannot be read, not to run on, when on would refuse", async () => { + const damaged = setup(); + damaged.consents.values.set(CWD, "off"); + damaged.history.damaged = true; + expect(await damaged.useCase.execute(CWD)).toEqual({ + status: "refused", + reason: "unreadable-consent", + }); + const unidentified = setup(); + unidentified.consents.values.set(CWD, "off"); + unidentified.locator.directories.set(CWD, { ...REPOSITORY, clone: null }); + expect(await unidentified.useCase.execute(CWD)).toEqual({ + status: "refused", + reason: "unreadable-consent", + }); + }); + + it("never says a clone with no key, or a key that grants nothing, was turned off", async () => { + const never = setup(); + never.consents.values.delete(CWD); + expect(await never.useCase.execute(CWD)).toEqual({ status: "refused", reason: "no-consent" }); + for (const key of ["2", "true", "{}", "OFF"]) { + const other = setup(); + other.consents.values.set(CWD, key); + expect(await other.useCase.execute(CWD)).toEqual({ status: "refused", reason: "no-consent" }); + } + }); }); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-consent.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-consent.unit.test.ts index fd8874189..33fdce000 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-consent.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-consent.unit.test.ts @@ -3,6 +3,7 @@ import { consentOf, consentValue, tokenOfKey, + turnedOff, } from "../../../../src/contexts/telemetry/domain/telemetry-consent.js"; const value = (text: string | null) => ({ kind: "value", value: text }) as const; @@ -41,3 +42,20 @@ describe("a clone's consent", () => { expect(consentOf({ kind: "unreadable" })).toBe("unreadable"); }); }); + +describe("a clone turned off", () => { + it("is one whose key is off, as `aidd telemetry off` writes it", () => { + expect(turnedOff(value("off"))).toBe(true); + }); + + it.each([[null], ["2"], ["2:t"], ["true"], ["{}"], ["OFF"], [" off"], ["off "], [""]])( + "is not one whose key is %j", + (text) => { + expect(turnedOff(value(text))).toBe(false); + } + ); + + it("is not one whose git config cannot be read", () => { + expect(turnedOff({ kind: "unreadable" })).toBe(false); + }); +}); diff --git a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts index 97ef7d2cc..350b99a19 100644 --- a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts +++ b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts @@ -151,6 +151,25 @@ describe("aidd telemetry on, choosing who is measured", () => { expect((await cli(["status"])).stdout).toContain("Measured anonymously."); }); + it("says, after on then off, that measurement was turned off and how to turn it back on", async () => { + const never = await cli(["status"]); + expect(never.exitCode).toBe(0); + expect(never.stdout).toContain("Not measured: this clone has not run `aidd telemetry on`."); + expect((await cli(["on", "--yes", "--anonymous"])).exitCode).toBe(0); + expect((await cli(["off"])).exitCode).toBe(0); + const status = await cli(["status"]); + expect(status.exitCode).toBe(0); + expect(status.stdout).toContain( + "Not measured: measurement was turned off in this clone. Run `aidd telemetry on` to measure it again." + ); + expect(status.stdout).not.toContain("has not run"); + const task = await cli(["task", "t1"]); + expect(task.exitCode).toBe(1); + expect(task.stderr).toContain("Measurement was turned off in this clone"); + expect((await cli(["on", "--yes", "--anonymous"])).exitCode).toBe(0); + expect((await cli(["status"])).stdout).toContain("This clone is measured since"); + }); + 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); diff --git a/cli/tests/presentation/display/telemetry-display.unit.test.ts b/cli/tests/presentation/display/telemetry-display.unit.test.ts index a6d6ed9c1..0575f904c 100644 --- a/cli/tests/presentation/display/telemetry-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry-display.unit.test.ts @@ -214,7 +214,12 @@ describe("printDeclareResult", () => { const refusal = (reason: Extract["reason"]) => linesOf((o) => printDeclareResult(o, { status: "refused", reason }))[0]; expect(refusal("environment")).toBe("AIDD_TELEMETRY=0: nothing was declared."); - expect(refusal("no-consent")).toContain("has not opted in"); + expect(refusal("no-consent")).toBe( + "This clone has not opted in to measurement, so no task was declared. Run `aidd telemetry on` here first." + ); + expect(refusal("turned-off")).toBe( + "Measurement was turned off in this clone, so no task was declared. Run `aidd telemetry on` here to measure it again." + ); expect(refusal("outside-repository")).toContain("Not inside a git repository"); expect(refusal("unreadable-consent")).toContain("cannot be read"); expect(refusal("unidentified-repository")).toContain("no remote and no commit"); @@ -301,5 +306,10 @@ describe("printTelemetryStatus", () => { expect( linesOf((o) => printTelemetryStatus(o, { status: "refused", reason: "no-consent" })) ).toEqual(["Not measured: this clone has not run `aidd telemetry on`."]); + expect( + linesOf((o) => printTelemetryStatus(o, { status: "refused", reason: "turned-off" })) + ).toEqual([ + "Not measured: measurement was turned off in this clone. Run `aidd telemetry on` to measure it again.", + ]); }); });