From ae39324f177aed590b9a7ebec622b86b8e87af99 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 20:49:56 +0200 Subject: [PATCH 01/13] docs(telemetry): frame what binds a branch and what the gate asks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../contract.md | 47 +++++++++++++++++++ .../phase-1.md | 22 +++++++++ .../phase-2.md | 19 ++++++++ .../phase-3.md | 11 +++++ .../plan.md | 29 ++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-2.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-3.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/plan.md diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md new file mode 100644 index 000000000..f16a36dd7 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md @@ -0,0 +1,47 @@ +--- +status: done +--- + +# A declaration comes only from the command + +## Need + +The product rule ([usage-contract](../../../product/usage-contract.md#attribution)): a task and a ticket come only from `aidd telemetry task`. The R1 telemetry audit found two ways around it, and one sentence of the contract that the hook does not do. + +| Finding | Observed | +| --- | --- | +| B1 | Both readers of a branch declaration (CLI `parseBranchConfig`, hook `lib/git.cjs` + `lib/binding.cjs`) bind a branch on a hand-set `aiddTask` with no `aiddDeclaredAt`. Attribution treats a snapshot with `declared_at: null` as speaking from the branch's creation. | +| B1 | `git branch -c A B` copies every `branch.A.*` key, `aiddDeclaredAt` included: B inherits a declaration it never received, and its calls go to A's task. | +| M8 | The contract says the gate "asks once per working branch". It blocks every prompt until the branch is declared. | + +## Decisions + +| # | Decision | Why | +| --- | --- | --- | +| 1 | The command also writes `branch..aiddDeclaredFor = `, before `aiddDeclaredAt`, which stays last. | Ties the declaration to the branch it was made on. A copy or a rename moves the keys under another name; the value keeps the old one. | +| 2 | A branch is declared only when `aiddDeclaredAt` is set and `aiddDeclaredFor`, when present, equals the branch read. Otherwise it is undeclared: no task, no ticket, no `none`. | `aiddDeclaredAt` is the last key the command writes, so it alone says the command finished. The stamp rejects a copied declaration. | +| 3 | A declaration with no `aiddDeclaredFor` (written by cli-v5.5.0 and earlier) is read as before, if `aiddDeclaredAt` is set. Declaring again stamps it. | Beta, but data written by a released CLI keeps its meaning. Rejecting it would make `status` and the gate ask again on every branch already declared, for a hole only a copy opens. | +| 4 | `git branch -m A B` leaves B undeclared: the gate asks on B, and one declaration on B covers it again from its creation. Calls made on A keep A's task. | git moves the config keys the same way for a copy and a rename; telling them apart means reading the reflog, whose entries expire. The cost is one question after a rename, and nothing is lost: the reflog moves with the branch, so B's first declaration still covers B from A's creation. This replaces "follows a rename" in the usage contract. | +| 5 | A branch snapshot with `declared_at: null` attributes nothing. | Such a snapshot comes only from keys set by hand or a declaration cut short: every release wrote `aiddDeclaredAt`. | +| 6 | M8: the doc is wrong, not the hook. The gate blocks every prompt on an undeclared working branch until it is declared (or declared `--none`). A declared branch is never asked again. | The spec that introduced the gate (`2026_10_09_deterministic-tokens/spec.md`) requires asking "before any token of that work is spent", and its target (`context/target.md`, "When to ask") re-checks every prompt. "Once" meant "remembered once answered". Letting later prompts through would spend tokens unattributed and need per-branch state. | + +## Limits (stated, not fixed) + +| Limit | Why it stays | +| --- | --- | +| Someone writing every key by hand, the stamp included, declares a branch. | git config is the person's own; no reader can tell who wrote a key. | +| A copy of an unstamped (legacy) declaration is inherited until the copy is declared again. | Decision 3. | +| Snapshots recorded before this change (a copied branch, a hand-set task with a `declared_at`) keep attributing. | Attribution reads only snapshots; `branches.jsonl` is append-only and its format is unchanged. | + +## Acceptance + +| # | Criterion | +| --- | --- | +| 1 | Sandboxed e2e: declare on A, `git branch -c A B`, calls on B: `report --axis ticket --json` does not give B's calls A's ticket. | +| 2 | Sandboxed e2e: `aiddTask` set by hand without `aiddDeclaredAt`: the branch's calls are `no-binding`. | +| 3 | Sandboxed e2e: after `git branch -m A B`, B's calls are `no-binding`; declaring on B attributes them. | +| 4 | Sandboxed e2e: an unstamped declaration with `aiddDeclaredAt` still attributes. | +| 5 | The hook applies the same rule, tested in `scripts/__tests__/` through real git, from the shared fixture both readers run. | +| 6 | `forget` removes `aiddDeclaredFor`. | +| 7 | Each guard is shown by a mutation that turns its test red. | +| 8 | The usage contract, the CLI telemetry memory, the plugin README and skill say decisions 1 to 6 and the limits. | diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-1.md new file mode 100644 index 000000000..b4581a8ff --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-1.md @@ -0,0 +1,22 @@ +--- +status: done +--- + +# Phase 1: The CLI writes the stamp and reads only the command's declaration + +## Architecture projection + +```txt +cli/src/contexts/telemetry/ +├── domain/branch-binding.ts ✏️ parseBranchConfig keeps only declared branches +├── domain/report/attribution.ts ✏️ a snapshot with no declared_at attributes nothing +├── infrastructure/git-branch-binding-source-adapter.ts ✏️ reads aiddDeclaredFor +├── infrastructure/declaration/git-branch-binding-store-adapter.ts ✏️ writes aiddDeclaredFor before aiddDeclaredAt +└── infrastructure/forget/repository-declarations-adapter.ts ✏️ removes aiddDeclaredFor +cli/tests/ ✏️ unit, integration, e2e (contract acceptance 1-4, 6) +``` + +## Tasks + +1. Red first: e2e copy, hand-set, rename then redeclare, legacy; unit parse cases; attribution null snapshot; store and forget adapters. Record red in `evidence.md`. +2. Code to green. One mutation per guard, recorded. diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-2.md new file mode 100644 index 000000000..98b21a189 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-2.md @@ -0,0 +1,19 @@ +--- +status: done +--- + +# Phase 2: The hook reads by the same rule + +## Architecture projection + +```txt +plugins/aidd-telemetry/hooks/lib/git.cjs ✏️ parseBranchConfig reads declared_for; branchDeclaration returns null unless declared by the command +plugins/aidd-telemetry/hooks/lib/binding.cjs ✏️ a branch binds when present +scripts/__tests__/fixtures/telemetry-bindings/ ✏️ cases: hand-set task unbound, copied stamp unbound, legacy bound, own stamp bound +scripts/__tests__/aidd-telemetry-hooks.test.js ✏️ gitConfig test reads expected answers, keys written under the case's branch +``` + +## Tasks + +1. Red first: fixture cases flipped and added; both the CLI fixture test and the hook test go red. Record. +2. Code to green. Mutation per guard, recorded. diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-3.md new file mode 100644 index 000000000..74f0ead03 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/phase-3.md @@ -0,0 +1,11 @@ +--- +status: done +--- + +# Phase 3: Contract, memory, plugin docs, gates + +## Tasks + +1. `aidd_docs/product/usage-contract.md`: store row, attribution rule, rename, legacy, limits, gate wording (M8). +2. `cli/aidd_docs/memory/telemetry.md`, `plugins/aidd-telemetry/README.md`, skill text where it describes declarations, `prompt-gate.cjs` header comment, fixture README. +3. Gates: `cd cli && pnpm test`, `pnpm test:mutation:telemetry` (under the lock), `node --test 'scripts/__tests__/*.test.js'`, bundle budget, `pnpm smoke`, `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` with and without `--attended`. diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/plan.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/plan.md new file mode 100644 index 000000000..8733c55b8 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/plan.md @@ -0,0 +1,29 @@ +--- +objective: "A branch is bound only by a declaration the command made on that branch, in the CLI and in the hook alike, and the contract says what the gate does." +status: done +--- + +# Plan: A declaration comes only from the command + +## Overview + +| Field | Value | +| ---------- | ----- | +| **Goal** | Hand-set, copied or renamed declaration keys bind nothing; legacy declarations still read; M8 doc fixed | +| **Source** | [`contract.md`](./contract.md), R1 telemetry audit (B1, M8), no issue | + +## Phases + +| # | Phase | File | +| --- | ----- | ---- | +| 1 | The CLI writes the stamp and reads only the command's declaration | [`phase-1.md`](./phase-1.md) | +| 2 | The hook reads by the same rule, from the shared fixture | [`phase-2.md`](./phase-2.md) | +| 3 | Contract, memory, plugin docs, gates | [`phase-3.md`](./phase-3.md) | + +## Decisions + +| Decision | Why | +| --- | --- | +| The stamp is a filter at config read, never stored in `BranchConfigBinding` or a snapshot | `branches.jsonl` keeps its format, no migration | +| An undeclared branch is left out of `parseBranchConfig`'s result | Nothing to snapshot, nothing to bind; `status` and attribution need no change of their own | +| The shared fixture `scripts/__tests__/fixtures/telemetry-bindings/` pins the rule | Both readers already run it; one case file, two tests | From 103d00964fc1c2a6540ab6c63bb3bf0aa64b36a9 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 21:04:01 +0200 Subject: [PATCH 02/13] fix(telemetry): bind a branch only by the command's own declaration A task set by hand without aiddDeclaredAt, or a declaration carried to another branch by git branch -c or -m, bound that branch in both the CLI and the hook. The command now stamps aiddDeclaredFor with the branch it declared on; both readers count a declaration only when aiddDeclaredAt is set and the stamp, when present, names the branch read. Unstamped declarations from earlier releases still read. A branch snapshot with no declaration time attributes nothing. forget removes the new key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../evidence.md | 106 ++++++++++++++++++ .../telemetry/domain/branch-binding.ts | 33 ++++-- .../telemetry/domain/report/attribution.ts | 40 ++++--- .../git-branch-binding-store-adapter.ts | 7 +- .../forget/repository-declarations-adapter.ts | 2 +- .../git-branch-binding-source-adapter.ts | 2 +- .../domain/branch-binding.unit.test.ts | 96 +++++++++++----- .../domain/report/attribution.unit.test.ts | 7 +- ...y-declarations-adapter.integration.test.ts | 5 +- ...binding-source-adapter.integration.test.ts | 11 ++ ...-binding-store-adapter.integration.test.ts | 11 +- .../telemetry-bindings-fixture.unit.test.ts | 11 +- .../telemetry-report-declaration.e2e.test.ts | 54 +++++++++ plugins/aidd-telemetry/hooks/lib/binding.cjs | 5 +- plugins/aidd-telemetry/hooks/lib/git.cjs | 25 ++++- .../__tests__/aidd-telemetry-hooks.test.js | 35 +++++- .../fixtures/telemetry-bindings/cases.json | 69 +++++++++++- .../fixtures/telemetry-bindings/expected.json | 54 +++++++-- 18 files changed, 488 insertions(+), 85 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md create mode 100644 cli/tests/e2e/telemetry-report-declaration.e2e.test.ts diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md new file mode 100644 index 000000000..8a0f8e78d --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md @@ -0,0 +1,106 @@ +# Evidence: A declaration comes only from the command + +Every run below is sandboxed (temp `HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR`, `GIT_CONFIG_NOSYSTEM=1`), as the suites set them. + +## Probe: what git does on copy and rename + +`git branch -c A B` and `git branch -m A C` in a scratch repository (git 2.x, macOS): + +```txt +branch.A.aiddtask t +branch.B.aiddtask t <- copy carries the key +B@{2026-10-01T08:00:00Z} <- and A's reflog, so A's creation time +Branch: copied refs/heads/A to refs/heads/B +C@{...} Branch: renamed refs/heads/A to refs/heads/C <- rename moves the key and the reflog +``` + +So a copied branch looks created when A was: the copy e2e below is red for the real reason, not for a creation time after the calls. + +## Red: e2e, before any code change + +`npx vitest run --project=e2e tests/e2e/telemetry-report-declaration.e2e.test.ts` + +```txt +× does not give a copied branch the declaration of the branch it was copied from + AssertionError: expected 3 to be 2 +× does not bind a branch on a task set by hand with no declaration time + AssertionError: expected { kind: 'value', key: 'by-hand', …(7) } to be undefined +× asks again after a rename, and one declaration covers the renamed branch + AssertionError: expected 3 to be 2 +Tests 3 failed | 1 passed (4) +``` + +The one passing is the legacy case (an unstamped declaration still attributes): green before, kept green by decision 3; its guard is a mutation below. + +## Red: CLI unit and integration, before any code change + +`npx vitest run` on the parse, attribution, source, store and forget specs: + +```txt +× reads nothing from a task or a ticket with no declaration time +× reads nothing from a declaration made for another branch, as a copy or a rename leaves it +× tells a stamp apart from a branch whose name differs only in case +× keeps the declared branches when another one is not declared +× lets a snapshot with no declaration time attribute nothing: no command wrote it +× reads nothing from a declaration the command did not make on that branch +× writes the keys the reader reads back, stamped with the branch it was made on +× leaves a renamed or copied branch undeclared: git moves the keys, the stamp keeps the old name +× removes exactly those keys, and says how many +Tests 9 failed | 116 passed (125) +``` + +## Red: the shared fixture, both readers, before any code change + +CLI, `npx vitest run tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts`: + +```txt +× reads task-without-declared-at as the fixture says +× reads stamped-for-another-branch as the fixture says +× reads stamp-without-declared-at as the fixture says +× binds branch-task-without-declared-at-is-unbound +× binds branch-stamped-for-another-branch-is-unbound +× binds branch-stamp-without-declared-at-is-unbound +Tests 6 failed | 107 passed (113) +``` + +Hook, `node --test scripts/__tests__/aidd-telemetry-hooks.test.js`: + +```txt +✖ a branch declaration read back from real git follows the fixture +✖ binding follows the fixture, through real git and the shared files +✖ parseBranchConfig cuts the known suffix off a dotted branch name +✖ a branch the command did not declare is asked: a task set by hand, a declaration copied from another branch +ℹ tests 65 ℹ pass 61 ℹ fail 4 +``` + +`the gate asks on every prompt until the branch is declared, then never again` passes before any change: it pins what the hook already does (M8, decision 6), and the doc is what changes. + +## Green after the change + +- `npx vitest run tests/contexts/telemetry`: `Tests 1223 passed (1223)` (with the e2e file above). +- `npx vitest run --project=e2e tests/e2e/telemetry`: `Test Files 8 passed (8)`, `Tests 76 passed (76)`. +- `node --test scripts/__tests__/aidd-telemetry-hooks.test.js`: `ℹ tests 65`, `ℹ fail 0`. + +## Guards: each mutation turns its test red + +Each mutation applied alone to the source, the named suite run, the file restored. + +| # | Mutation | Suite | Red | +| --- | --- | --- | --- | +| M1 | `parseBranchConfig` keeps every branch (filter removed) | `vitest tests/contexts/telemetry` | 8+ tests, incl. `reads nothing from a task or a ticket with no declaration time` | +| M2 | stamp ignored (`declared_at !== null` only) | same | 7: `reads nothing from a declaration made for another branch…`, `binds branch-stamped-for-another-branch-is-unbound`, … | +| M2 | same | e2e declaration file | 2: copy, rename | +| M3 | `declared_at` not required | `vitest tests/contexts/telemetry` | 7: `reads nothing from a task or a ticket with no declaration time`, `binds branch-task-without-declared-at-is-unbound`, … | +| M3 | same | e2e declaration file | 0: the attribution guard (M6) still drops the snapshot. With M3 and M6 together: `× does not bind a branch on a task set by hand with no declaration time` | +| M4 | unstamped (legacy) rejected | `vitest tests/contexts/telemetry` | 8+: `still reads a declaration written before declarations were stamped`, … | +| M4 | same | e2e declaration file | 1: `still reads a declaration written before the branch was stamped` | +| M5 | store writes no `aiddDeclaredFor` | `vitest tests/contexts/telemetry` | 2: `writes the keys … stamped …`, `leaves a renamed or copied branch undeclared…` | +| M5 | same | e2e declaration file | 2: copy, rename | +| M6 | attribution keeps snapshots with no `declared_at` | `vitest tests/contexts/telemetry` | 1: `lets a snapshot with no declaration time attribute nothing…` | +| M7 | forget regex without `aidddeclaredfor` | same | 1: `removes exactly those keys, and says how many` | +| M8 | source regex without `declaredfor` | same | 2: `reads nothing from a declaration the command did not make on that branch`, rename/copy | +| H1 | hook: stamp ignored | `node --test …hooks.test.js` | 3: fixture via git, binding fixture, `a branch the command did not declare is asked…` | +| H2 | hook: `declared_at` not required | same | 3: same three | +| H3 | hook: legacy rejected | same | 3: fixture via git, binding fixture, `a bound branch, session or carried session passes` | +| H4 | hook regex without `declaredfor` | same | 3 | +| H5 | hook: `branchDeclaration` returns what it read, declared or not | same | 3 | diff --git a/cli/src/contexts/telemetry/domain/branch-binding.ts b/cli/src/contexts/telemetry/domain/branch-binding.ts index eadd93fc7..cf7bc9122 100644 --- a/cli/src/contexts/telemetry/domain/branch-binding.ts +++ b/cli/src/contexts/telemetry/domain/branch-binding.ts @@ -31,22 +31,33 @@ const PREFIX = "branch."; const FIELDS = { task: ".aiddtask", ticket: ".aiddticket", + declared_for: ".aidddeclaredfor", declared_at: ".aidddeclaredat", } as const; interface DeclaredFields { task: string | null; ticket: string | null; + /** The branch the command declared on: a copy or a rename moves the keys, not this name. */ + declared_for: string | null; declared_at: string | null; } +/** Only the command declares, and it writes `aiddDeclaredAt` last: a key without it was set by + * hand or cut short. A stamp naming another branch was carried here by `git branch -c` or + * `-m`. A declaration with no stamp was written before stamping existed and still counts. */ +function declaredByTheCommand(branch: string, fields: DeclaredFields): boolean { + return fields.declared_at !== null && (fields.declared_for ?? branch) === branch; +} + function textOrNull(value: string): string | null { return value === "" ? null : value; } -/** Reads `git config --local -z --get-regexp` output: entries end in NUL, a key and its value - * are split by a newline, and a key given with no value has no newline at all. A branch name - * can hold dots, so the known suffix is cut off the end, never split on. */ +/** Reads `git config --local -z --get-regexp` output into the branches the command declared: + * entries end in NUL, a key and its value are split by a newline, and a key given with no value + * has no newline at all. A branch name can hold dots, so the known suffix is cut off the end, + * never split on. */ export function parseBranchConfig(output: string): BranchConfigBinding[] { const branches = new Map(); for (const entry of output.split("\0")) { @@ -58,17 +69,25 @@ export function parseBranchConfig(output: string): BranchConfigBinding[] { for (const [field, suffix] of Object.entries(FIELDS) as [keyof DeclaredFields, string][]) { if (!lower.endsWith(suffix)) continue; const branch = key.slice(PREFIX.length, key.length - suffix.length); - const held = branches.get(branch) ?? { task: null, ticket: null, declared_at: null }; + const held = branches.get(branch) ?? { + task: null, + ticket: null, + declared_for: null, + declared_at: null, + }; held[field] = textOrNull(value); branches.set(branch, held); } } return [...branches.entries()] + .filter(([branch, fields]) => declaredByTheCommand(branch, fields)) .sort(([a], [b]) => compareText(a, b)) - .map(([branch, fields]) => ({ + .map(([branch, { task, ticket, declared_at }]) => ({ branch, - ...fields, - none: fields.declared_at !== null && fields.task === null, + task, + ticket, + declared_at, + none: task === null, })); } diff --git a/cli/src/contexts/telemetry/domain/report/attribution.ts b/cli/src/contexts/telemetry/domain/report/attribution.ts index 3dda94efc..a2d2db6d4 100644 --- a/cli/src/contexts/telemetry/domain/report/attribution.ts +++ b/cli/src/contexts/telemetry/domain/report/attribution.ts @@ -61,20 +61,28 @@ function sessionVerdict(record: AttributableUsage, at: number, facts: Attributio return binding.state === "bound" ? verdict(binding.task, binding.ticket) : null; } -/** The instant a snapshot's declaration was made; one with no time speaks from the start. */ -function declaredAt(snapshot: BranchSnapshot): number { - return snapshot.declared_at === null - ? Number.NEGATIVE_INFINITY - : Date.parse(snapshot.declared_at); +/** A snapshot of a declaration the command made: it always has a time. */ +type DeclaredSnapshot = BranchSnapshot & { readonly declared_at: string }; + +/** A snapshot with no declaration time records keys no command wrote: it binds nothing. */ +function isDeclared(snapshot: BranchSnapshot): snapshot is DeclaredSnapshot { + return snapshot.declared_at !== null; +} + +function declaredAt(snapshot: DeclaredSnapshot): number { + return Date.parse(snapshot.declared_at); } /** The declaration in force at an instant within one generation. The generation's first * declaration covers it from its creation, so declaring late still moves its earlier work; * each later one applies from its own time, so work already attributed stays where it was. * Equal declaration times are one declaration: the latest snapshot of it speaks. */ -function inForce(generation: readonly BranchSnapshot[], at: number): BranchSnapshot | undefined { - let first: BranchSnapshot | undefined; - let current: BranchSnapshot | undefined; +function inForce( + generation: readonly DeclaredSnapshot[], + at: number +): DeclaredSnapshot | undefined { + let first: DeclaredSnapshot | undefined; + let current: DeclaredSnapshot | undefined; for (const snapshot of generation) { if (first === undefined || declaredAt(snapshot) < declaredAt(first)) first = snapshot; if ( @@ -90,9 +98,9 @@ function inForce(generation: readonly BranchSnapshot[], at: number): BranchSnaps /** The latest snapshot of the earliest declaration. */ function latestOfFirst( - generation: readonly BranchSnapshot[], - first: BranchSnapshot -): BranchSnapshot { + generation: readonly DeclaredSnapshot[], + first: DeclaredSnapshot +): DeclaredSnapshot { return generation.filter((s) => declaredAt(s) === declaredAt(first)).at(-1) ?? first; } @@ -102,9 +110,9 @@ function latestOfFirst( * declaration in force at the call (see `inForce`). A snapshot with no creation time speaks * only when no dated generation was created before the call. */ function generationAt( - snapshots: readonly BranchSnapshot[], + snapshots: readonly DeclaredSnapshot[], at: number -): BranchSnapshot | undefined { +): DeclaredSnapshot | undefined { let chosenCreated = Number.NEGATIVE_INFINITY; let found = false; for (const snapshot of snapshots) { @@ -132,10 +140,8 @@ function branchVerdict( facts: AttributionFacts ): Attribution | null { if (record.git_branch === null) return null; - const branch = generationAt( - facts.branches.get(snapshotKey(record.repository_id, record.git_branch)) ?? [], - at - ); + const snapshots = facts.branches.get(snapshotKey(record.repository_id, record.git_branch)) ?? []; + const branch = generationAt(snapshots.filter(isDeclared), at); if (branch === undefined || (branch.task === null && !branch.none)) return null; return verdict(branch.task, branch.ticket); } diff --git a/cli/src/contexts/telemetry/infrastructure/declaration/git-branch-binding-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/declaration/git-branch-binding-store-adapter.ts index e18d5995c..d2e6552fb 100644 --- a/cli/src/contexts/telemetry/infrastructure/declaration/git-branch-binding-store-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/declaration/git-branch-binding-store-adapter.ts @@ -7,7 +7,7 @@ const NOT_SYMBOLIC = 1; const KEY_ABSENT = 5; /** Keeps a branch's declaration in the repository's own git config: `branch..aiddTask`, - * `aiddTicket` and `aiddDeclaredAt`. */ + * `aiddTicket`, `aiddDeclaredFor` (the branch it was made on) and `aiddDeclaredAt`. */ export class GitBranchBindingStoreAdapter implements BranchBindingStore { /** `env` carries none of git's own variables, which would point it at another repository. */ constructor(private readonly env: NodeJS.ProcessEnv) {} @@ -20,10 +20,13 @@ export class GitBranchBindingStoreAdapter implements BranchBindingStore { } /** `aiddDeclaredAt` goes last: a branch counts as declared only once the rest is in place, - * so a crash in between leaves it undeclared rather than half declared. */ + * so a crash in between leaves it undeclared rather than half declared. `aiddDeclaredFor` + * names the branch, so a copy or a rename, which carries the keys away, carries no + * declaration. */ async declare(root: string, branch: string, declaration: TaskDeclaration): Promise { this.setOrUnset(root, branch, "aiddTask", declaration.task); this.setOrUnset(root, branch, "aiddTicket", declaration.ticket); + this.setOrUnset(root, branch, "aiddDeclaredFor", branch); this.setOrUnset(root, branch, "aiddDeclaredAt", declaration.declared_at); } diff --git a/cli/src/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.ts b/cli/src/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.ts index 56d2aa007..59c1dc892 100644 --- a/cli/src/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.ts @@ -13,7 +13,7 @@ const NO_MATCH = 1; const KEY_ABSENT = 5; /** The three keys a declaration writes, as git lists them: the variable in lower case, the * branch name, which may hold dots, kept as it is. */ -const TASK_KEYS = "^branch\\..+\\.(aiddtask|aiddticket|aidddeclaredat)$"; +const TASK_KEYS = "^branch\\..+\\.(aiddtask|aiddticket|aidddeclaredfor|aidddeclaredat)$"; export class RepositoryDeclarationsAdapter implements RepositoryDeclarations { /** `env` carries none of git's own variables, which would point it at another repository. */ diff --git a/cli/src/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.ts b/cli/src/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.ts index 665b6ab66..36073dd6e 100644 --- a/cli/src/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.ts @@ -7,7 +7,7 @@ import type { BranchBindingSource } from "../domain/ports/branch-binding-source. import { runGit } from "./run-git.js"; const NO_MATCH = 1; -const DECLARATION_KEYS = "^branch\\..*\\.aidd(task|ticket|declaredat)$"; +const DECLARATION_KEYS = "^branch\\..*\\.aidd(task|ticket|declaredfor|declaredat)$"; /** Reads branch declarations from a repository's own git config and reflog. */ export class GitBranchBindingSourceAdapter implements BranchBindingSource { diff --git a/cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts b/cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts index a551333e2..b89d90aad 100644 --- a/cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts @@ -12,39 +12,50 @@ import { const entry = (key: string, value: string): string => `${key}\n${value}`; const listing = (...entries: string[]): string => entries.map((e) => `${e}\0`).join(""); +const D = "2026-10-07T10:00:00.000Z"; +const declaredAt = (branch: string): string => entry(`branch.${branch}.aidddeclaredat`, D); + describe("git config output is read into bindings", () => { - it("reads the three keys of a branch, which git lists in lower case", () => { + it("reads the keys of a branch, which git lists in lower case", () => { const out = listing( entry("branch.feat/x.aiddtask", "checkout-fix"), entry("branch.feat/x.aiddticket", "PROJ-12"), - entry("branch.feat/x.aidddeclaredat", "2026-10-07T10:00:00.000Z") + entry("branch.feat/x.aidddeclaredfor", "feat/x"), + entry("branch.feat/x.aidddeclaredat", D) ); expect(parseBranchConfig(out)).toEqual([ { branch: "feat/x", task: "checkout-fix", ticket: "PROJ-12", - declared_at: "2026-10-07T10:00:00.000Z", + declared_at: D, none: false, }, ]); }); it("reads the keys whatever case they are spelt in", () => { - const out = listing(entry("branch.x.aiddTask", "t"), entry("branch.x.AIDDDeclaredAt", "d")); + const out = listing( + entry("branch.x.aiddTask", "t"), + entry("branch.x.AIDDDeclaredFor", "x"), + entry("branch.x.AIDDDeclaredAt", "d") + ); expect(parseBranchConfig(out)).toMatchObject([{ branch: "x", task: "t", declared_at: "d" }]); }); it("keeps a branch name with dots and mixed case whole", () => { - const out = listing(entry("branch.Release/1.2.aiddtask", "t")); + const out = listing(entry("branch.Release/1.2.aiddtask", "t"), declaredAt("Release/1.2")); expect(parseBranchConfig(out).map((b) => b.branch)).toEqual(["Release/1.2"]); }); it("lists branches apart, in name order", () => { const out = listing( entry("branch.b.aiddtask", "tb"), + declaredAt("b"), entry("branch.c.aiddtask", "tc"), - entry("branch.a.aiddtask", "ta") + declaredAt("c"), + entry("branch.a.aiddtask", "ta"), + declaredAt("a") ); expect(parseBranchConfig(out).map((b) => [b.branch, b.task])).toEqual([ ["a", "ta"], @@ -54,47 +65,80 @@ describe("git config output is read into bindings", () => { }); it("reads a declaration of no task as none", () => { - const out = listing(entry("branch.x.aidddeclaredat", "2026-10-07T10:00:00.000Z")); + const out = listing(declaredAt("x")); expect(parseBranchConfig(out)).toEqual([ - { - branch: "x", - task: null, - ticket: null, - declared_at: "2026-10-07T10:00:00.000Z", - none: true, - }, + { branch: "x", task: null, ticket: null, declared_at: D, none: true }, ]); }); it("reads an empty task as none too", () => { - const out = listing( - entry("branch.x.aiddtask", ""), - entry("branch.x.aidddeclaredat", "2026-10-07T10:00:00.000Z") - ); + const out = listing(entry("branch.x.aiddtask", ""), declaredAt("x")); expect(parseBranchConfig(out)[0]).toMatchObject({ task: null, none: true }); }); - it("does not call a branch none while it has no declaration time", () => { - const out = listing(entry("branch.x.aiddticket", "T-1")); - expect(parseBranchConfig(out)[0]).toMatchObject({ task: null, declared_at: null, none: false }); - }); - it("keeps a value holding a newline whole", () => { - const out = listing(entry("branch.x.aiddtask", "line one\nline two")); + const out = listing(entry("branch.x.aiddtask", "line one\nline two"), declaredAt("x")); expect(parseBranchConfig(out)[0]?.task).toBe("line one\nline two"); }); it("reads a key given with no value as an empty one", () => { - expect(parseBranchConfig("branch.x.aiddtask\0")[0]).toMatchObject({ task: null }); + expect(parseBranchConfig(`branch.x.aiddtask\0${listing(declaredAt("x"))}`)[0]).toMatchObject({ + task: null, + none: true, + }); }); - it("ignores keys that are not the three, and no output at all", () => { + it("ignores keys that are not the four, and no output at all", () => { expect(parseBranchConfig(listing(entry("branch.x.remote", "origin")))).toEqual([]); expect(parseBranchConfig(listing(entry("core.aiddtask", "x")))).toEqual([]); expect(parseBranchConfig("")).toEqual([]); }); }); +describe("only a declaration the command made on the branch counts", () => { + it("reads nothing from a task or a ticket with no declaration time", () => { + const out = listing( + entry("branch.x.aiddtask", "by-hand"), + entry("branch.y.aiddticket", "T-1"), + entry("branch.z.aidddeclaredfor", "z") + ); + expect(parseBranchConfig(out)).toEqual([]); + }); + + it("reads nothing from a declaration made for another branch, as a copy or a rename leaves it", () => { + const out = listing( + entry("branch.copy.aiddtask", "theirs"), + entry("branch.copy.aidddeclaredfor", "original"), + declaredAt("copy") + ); + expect(parseBranchConfig(out)).toEqual([]); + }); + + it("tells a stamp apart from a branch whose name differs only in case", () => { + const out = listing( + entry("branch.Feat.aiddtask", "t"), + entry("branch.Feat.aidddeclaredfor", "feat"), + declaredAt("Feat") + ); + expect(parseBranchConfig(out)).toEqual([]); + }); + + it("still reads a declaration written before declarations were stamped", () => { + const out = listing(entry("branch.x.aiddtask", "legacy"), declaredAt("x")); + expect(parseBranchConfig(out)).toMatchObject([{ branch: "x", task: "legacy" }]); + }); + + it("keeps the declared branches when another one is not declared", () => { + const out = listing( + entry("branch.a.aiddtask", "kept"), + entry("branch.a.aidddeclaredfor", "a"), + declaredAt("a"), + entry("branch.b.aiddtask", "dropped") + ); + expect(parseBranchConfig(out).map((b) => b.task)).toEqual(["kept"]); + }); +}); + describe("the oldest reflog entry is the branch's creation", () => { it("takes the last line, which git lists newest first, as UTC", () => { const out = [ diff --git a/cli/tests/contexts/telemetry/domain/report/attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/report/attribution.unit.test.ts index ffcf54d97..28aa13f72 100644 --- a/cli/tests/contexts/telemetry/domain/report/attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/report/attribution.unit.test.ts @@ -367,11 +367,14 @@ describe("attribution: a branch name used again", () => { expect(attribute(onBranch("2026-10-07T00:00:00.000Z"), f)).toEqual(task("beta")); }); - it("lets a snapshot with no declaration time cover from the creation, before the dated ones", () => { + it("lets a snapshot with no declaration time attribute nothing: no command wrote it", () => { const orphan = snapshot({ task: "orphan", declared_at: null }); + expect(attribute(onBranch("2026-10-02T00:00:00.000Z"), facts({ branches: [orphan] }))).toEqual( + unattributed("no-binding") + ); const dated = snapshot({ task: "dated", declared_at: "2026-10-06T00:00:00.000Z" }); const f = facts({ branches: [dated, orphan] }); - expect(attribute(onBranch("2026-10-02T00:00:00.000Z"), f)).toEqual(task("orphan")); + expect(attribute(onBranch("2026-10-02T00:00:00.000Z"), f)).toEqual(task("dated")); expect(attribute(onBranch("2026-10-07T00:00:00.000Z"), f)).toEqual(task("dated")); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.integration.test.ts index 69d4626b4..23e05e3e4 100644 --- a/cli/tests/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.integration.test.ts @@ -56,17 +56,18 @@ describe("the declarations in a repository's git config", () => { "branch.main.aiddTask", "branch.feat/a.b.aiddTicket", "branch.Feat/X.aiddDeclaredAt", + "branch.Feat/X.aiddDeclaredFor", "branch.main.remote", "branch.main.aiddOther" ); expect(await new RepositoryDeclarationsAdapter(env).clear(clone)).toEqual({ - taskKeys: 3, + taskKeys: 4, consent: false, }); const left = listed(); expect(left).toContain("branch.main.remote=v"); expect(left).toContain("branch.main.aiddother=v"); - expect(left).not.toMatch(/aiddtask|aiddticket|aidddeclaredat/); + expect(left).not.toMatch(/aiddtask|aiddticket|aidddeclaredat|aidddeclaredfor/); }); it("counts and removes the consent beside the task keys, and nothing like it", async () => { diff --git a/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.integration.test.ts index 1fe9e558c..8d6fc9c63 100644 --- a/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-source-adapter.integration.test.ts @@ -29,6 +29,7 @@ describe("reading branch declarations from git config", () => { it("reads the keys as the declaration command will write them", async () => { git(repo, env, "config", "branch.feat/x.aiddTask", "checkout-fix"); git(repo, env, "config", "branch.feat/x.aiddTicket", "PROJ-12"); + git(repo, env, "config", "branch.feat/x.aiddDeclaredFor", "feat/x"); git(repo, env, "config", "branch.feat/x.aiddDeclaredAt", "2026-10-07T10:00:00.000Z"); expect(await source.bindings(repo)).toEqual([ { @@ -43,6 +44,7 @@ describe("reading branch declarations from git config", () => { it("reads a branch named with dots and capitals whole", async () => { git(repo, env, "config", "branch.Release/1.2.aiddTask", "t"); + git(repo, env, "config", "branch.Release/1.2.aiddDeclaredAt", "2026-10-07T10:00:00.000Z"); expect((await source.bindings(repo)).map((b) => b.branch)).toEqual(["Release/1.2"]); }); @@ -51,6 +53,14 @@ describe("reading branch declarations from git config", () => { expect(await source.bindings(repo)).toMatchObject([{ branch: "x", task: null, none: true }]); }); + it("reads nothing from a declaration the command did not make on that branch", async () => { + git(repo, env, "config", "branch.by-hand.aiddTask", "t"); + git(repo, env, "config", "branch.copy.aiddTask", "t"); + git(repo, env, "config", "branch.copy.aiddDeclaredFor", "original"); + git(repo, env, "config", "branch.copy.aiddDeclaredAt", "2026-10-07T10:00:00.000Z"); + expect(await source.bindings(repo)).toEqual([]); + }); + it("ignores branch settings that are not declarations", async () => { git(repo, env, "config", "branch.main.remote", "origin"); expect(await source.bindings(repo)).toEqual([]); @@ -58,6 +68,7 @@ describe("reading branch declarations from git config", () => { it("reads the same keys from a linked worktree, which shares the repository's config", async () => { git(repo, env, "config", "branch.x.aiddTask", "t"); + git(repo, env, "config", "branch.x.aiddDeclaredAt", "2026-10-07T10:00:00.000Z"); const linked = join(base, "linked"); git(repo, env, "worktree", "add", "-q", "-b", "wt", linked); expect((await source.bindings(linked)).map((b) => b.branch)).toEqual(["x"]); diff --git a/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts index b5555f74c..c8a71d8e3 100644 --- a/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts @@ -63,10 +63,11 @@ describe("the branch HEAD is on", () => { }); describe("declaring on a branch", () => { - it("writes the three keys the reader reads back", async () => { + it("writes the keys the reader reads back, stamped with the branch it was made on", async () => { await store.declare(repo, "feat/x", task("checkout-fix", "PROJ-12")); expect(config("branch.feat/x.aiddTask")).toBe("checkout-fix"); expect(config("branch.feat/x.aiddTicket")).toBe("PROJ-12"); + expect(config("branch.feat/x.aiddDeclaredFor")).toBe("feat/x"); expect(config("branch.feat/x.aiddDeclaredAt")).toBe("2026-10-09T10:00:00.000Z"); expect(await new GitBranchBindingSourceAdapter(env).bindings(repo)).toEqual([ { @@ -79,11 +80,17 @@ describe("declaring on a branch", () => { ]); }); - it("survives a rename of the branch", async () => { + it("leaves a renamed or copied branch undeclared: git moves the keys, the stamp keeps the old name", async () => { await store.declare(repo, "feat/x", task("checkout-fix")); + git(repo, env, "branch", "-c", "feat/x", "feat/copy"); git(repo, env, "branch", "-m", "feat/x", "feat/renamed"); expect(config("branch.feat/renamed.aiddTask")).toBe("checkout-fix"); expect(absent("branch.feat/x.aiddTask")).toBe(true); + expect(await new GitBranchBindingSourceAdapter(env).bindings(repo)).toEqual([]); + await store.declare(repo, "feat/renamed", task("again")); + expect( + (await new GitBranchBindingSourceAdapter(env).bindings(repo)).map((b) => [b.branch, b.task]) + ).toEqual([["feat/renamed", "again"]]); }); it("drops a ticket the new declaration does not name", async () => { diff --git a/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts b/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts index 096d38f5b..85a80dbbe 100644 --- a/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts +++ b/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts @@ -117,11 +117,12 @@ describe("the shared fixture of task bindings", () => { Object.keys((cases as Record)[section] ?? {}).sort() ); } - expect(Object.keys(expected).sort()).toEqual( - Object.keys(cases) - .filter((section) => section !== "gitConfig") - .sort() - ); + expect(Object.keys(expected).sort()).toEqual(Object.keys(cases).sort()); + }); + + it.each(Object.entries(cases.gitConfig))("reads %s as the fixture says", (name, config) => { + const read = parseBranchConfig(gitConfigOutput(config)); + expect(read.length === 1 ? "declared" : "undeclared").toBe(expected.gitConfig?.[name]); }); it("holds only lines the CLI reads", () => { diff --git a/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts b/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts new file mode 100644 index 000000000..ced3f2d8c --- /dev/null +++ b/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { git } from "../helpers/git-sandbox.js"; +import { + aidd, + declareBranch, + json, + rowsOf, + sandbox, + useReportSandbox, +} from "./telemetry-report-sandbox.js"; + +useReportSandbox(); + +/* In the sandbox, call 3 is the only call on feat/b whose session declared nothing, so the + * branch alone decides it. Calls 1 and 2 are on feat/a, also decided by the branch. */ +const gitHere = (...args: string[]) => git(sandbox.repo, sandbox.gitEnv, ...args); + +describe("a branch is bound only by a declaration the command made on it", () => { + it("does not give a copied branch the declaration of the branch it was copied from", async () => { + await declareBranch(); + gitHere("branch", "-D", "feat/b"); + gitHere("branch", "-c", "feat/a", "feat/b"); + const tickets = rowsOf(await json("ticket")); + expect(tickets["PROJ-1"]?.records).toBe(2); + expect(tickets["no-binding"]?.records).toBe(1); + }); + + it("does not bind a branch on a task set by hand with no declaration time", async () => { + gitHere("config", "--local", "branch.feat/b.aiddTask", "by-hand"); + const tasks = rowsOf(await json("task")); + expect(tasks["by-hand"]).toBeUndefined(); + expect(tasks["no-binding"]?.records).toBe(3); + }); + + it("asks again after a rename, and one declaration covers the renamed branch", async () => { + await declareBranch(); + gitHere("branch", "-D", "feat/b"); + gitHere("branch", "-m", "feat/a", "feat/b"); + const renamed = rowsOf(await json("task")); + expect(renamed.checkout?.records).toBe(2); + expect(renamed["no-binding"]?.records).toBe(1); + expect((await aidd(["task", "after-rename"])).exitCode).toBe(0); + const declared = rowsOf(await json("task")); + expect(declared.checkout?.records).toBe(2); + expect(declared["after-rename"]?.records).toBe(1); + expect(declared["no-binding"]).toBeUndefined(); + }); + + it("still reads a declaration written before the branch was stamped", async () => { + gitHere("config", "--local", "branch.feat/b.aiddTask", "legacy"); + gitHere("config", "--local", "branch.feat/b.aiddDeclaredAt", "2026-10-06T00:00:00.000Z"); + expect(rowsOf(await json("task")).legacy?.records).toBe(1); + }); +}); diff --git a/plugins/aidd-telemetry/hooks/lib/binding.cjs b/plugins/aidd-telemetry/hooks/lib/binding.cjs index da2ba50d5..036bf0f14 100644 --- a/plugins/aidd-telemetry/hooks/lib/binding.cjs +++ b/plugins/aidd-telemetry/hooks/lib/binding.cjs @@ -72,12 +72,13 @@ function resolveSession(facts) { return sessionBinding(facts, facts.sessionId, facts.at.getTime(), new Set()); } -/** `branch` is the working branch's declaration ({task, ticket, declared_at}), or null. */ +/** `branch` is the working branch's declaration ({task, ticket, declared_at}) as the command + * made it, or null. */ function resolveBinding(facts) { const session = resolveSession(facts); if (session !== null) return session; const branch = facts.branch; - if (branch && (branch.task !== null || branch.declared_at !== null)) { + if (branch) { const none = branch.task === null; return { state: "bound", source: "branch", task: branch.task, ticket: branch.ticket, none }; } diff --git a/plugins/aidd-telemetry/hooks/lib/git.cjs b/plugins/aidd-telemetry/hooks/lib/git.cjs index b8763951d..7aab98aad 100644 --- a/plugins/aidd-telemetry/hooks/lib/git.cjs +++ b/plugins/aidd-telemetry/hooks/lib/git.cjs @@ -49,13 +49,18 @@ function branchRoleOf(headRef, originHeadRef) { return isDefault ? "default" : "working"; } -const FIELDS = { task: ".aiddtask", ticket: ".aiddticket", declared_at: ".aidddeclaredat" }; +const FIELDS = { + task: ".aiddtask", + ticket: ".aiddticket", + declared_for: ".aidddeclaredfor", + declared_at: ".aidddeclaredat", +}; /** `git config --local -z --get-regexp` output for one branch. git lists the variable part of * a key in lower case and the branch name keeps its case; the branch name may hold dots, so * the known suffix is cut off the end. */ function parseBranchConfig(output, branch) { - const held = { task: null, ticket: null, declared_at: null }; + const held = { task: null, ticket: null, declared_for: null, declared_at: null }; for (const entry of output.split("\0")) { if (!entry.startsWith("branch.")) continue; const newline = entry.indexOf("\n"); @@ -72,10 +77,20 @@ function parseBranchConfig(output, branch) { return held; } +/** Only the command declares, and it writes `aiddDeclaredAt` last: a key without it was set by + * hand or cut short. A stamp naming another branch was carried here by `git branch -c` or + * `-m`. A declaration with no stamp was written before stamping existed and still counts. */ +function declaredByTheCommand(held, branch) { + return held.declared_at !== null && (held.declared_for ?? branch) === branch; +} + +/** The branch's declaration ({task, ticket, declared_at}) when the command made it on this + * branch, else null. */ function branchDeclaration(cwd, branch, env) { - const run = git(cwd, ["config", "--local", "-z", "--get-regexp", "^branch\\..*\\.aidd(task|ticket|declaredat)$"], env); - if (run.status !== 0) return { task: null, ticket: null, declared_at: null }; - return parseBranchConfig(run.stdout, branch); + const run = git(cwd, ["config", "--local", "-z", "--get-regexp", "^branch\\..*\\.aidd(task|ticket|declaredfor|declaredat)$"], env); + if (run.status !== 0) return null; + const held = parseBranchConfig(run.stdout, branch); + return declaredByTheCommand(held, branch) ? { task: held.task, ticket: held.ticket, declared_at: held.declared_at } : null; } /** HEAD and origin/HEAD for the working tree at `cwd`, and the role they give. */ diff --git a/scripts/__tests__/aidd-telemetry-hooks.test.js b/scripts/__tests__/aidd-telemetry-hooks.test.js index b9ec154dd..7f85e6155 100644 --- a/scripts/__tests__/aidd-telemetry-hooks.test.js +++ b/scripts/__tests__/aidd-telemetry-hooks.test.js @@ -291,7 +291,7 @@ test("a branch declaration read back from real git follows the fixture", () => { git(box.repo, "config", "--local", `branch.${c.branch}.${key}`, value); } const found = lookup({ cwd: box.repo, sessionId: null, at: new Date(), dir: box.tel, env: process.env }); - const want = c.keys.aiddTask || c.keys.aiddDeclaredAt ? "bound" : "unbound"; + const want = expected.gitConfig[name] === "declared" ? "bound" : "unbound"; assert.equal(found.binding.state, want, name); }); } @@ -352,7 +352,12 @@ test("the shared files are read in full by the hook's own readers", () => { test("parseBranchConfig cuts the known suffix off a dotted branch name", () => { const out = "branch.release/1.2.aidd.aiddtask\nx\0branch.release/1.2.aidd.aidddeclaredat\nt\0"; - assert.deepEqual(parseBranchConfig(out, "release/1.2.aidd"), { task: "x", ticket: null, declared_at: "t" }); + assert.deepEqual(parseBranchConfig(out, "release/1.2.aidd"), { + task: "x", + ticket: null, + declared_for: null, + declared_at: "t", + }); }); // ---------------------------------------------------------------- the guards, end to end @@ -670,6 +675,8 @@ test("the default branch and a detached head are never asked, and git is not nee test("a bound branch, session or carried session passes", () => { withBox({}, (box) => { git(box.repo, "config", "--local", "branch.feat/x.aiddTask", "t"); + git(box.repo, "config", "--local", "branch.feat/x.aiddDeclaredFor", "feat/x"); + git(box.repo, "config", "--local", "branch.feat/x.aiddDeclaredAt", "2026-10-09T09:00:00.000Z"); assertPasses(gate(box)); }); withBox({}, (box) => { @@ -691,6 +698,30 @@ test("a bound branch, session or carried session passes", () => { }); }); +test("a branch the command did not declare is asked: a task set by hand, a declaration copied from another branch", () => { + withBox({}, (box) => { + git(box.repo, "config", "--local", "branch.feat/x.aiddTask", "by-hand"); + assert.equal(gate(box).json.decision, "block"); + }); + withBox({}, (box) => { + git(box.repo, "config", "--local", "branch.feat/x.aiddTask", "theirs"); + git(box.repo, "config", "--local", "branch.feat/x.aiddDeclaredFor", "feat/original"); + git(box.repo, "config", "--local", "branch.feat/x.aiddDeclaredAt", "2026-10-09T09:00:00.000Z"); + assert.equal(gate(box).json.decision, "block"); + }); +}); + +test("the gate asks on every prompt until the branch is declared, then never again", () => { + withBox({}, (box) => { + assert.equal(gate(box).json.decision, "block"); + assert.equal(gate(box).json.decision, "block"); + git(box.repo, "config", "--local", "branch.feat/x.aiddDeclaredFor", "feat/x"); + git(box.repo, "config", "--local", "branch.feat/x.aiddDeclaredAt", "2026-10-09T09:00:00.000Z"); + assertPasses(gate(box)); + assertPasses(gate(box)); + }); +}); + test("answer path: with aidd absent, or without telemetry task, an unbound branch is never blocked", () => { withBox({ aidd: false }, (box) => { assert.ok(!fs.existsSync(path.join(GIT_DIR_ON_PATH, WINDOWS ? "aidd.cmd" : "aidd")), "a real aidd is on the test PATH"); diff --git a/scripts/__tests__/fixtures/telemetry-bindings/cases.json b/scripts/__tests__/fixtures/telemetry-bindings/cases.json index b6c9024d4..2fa9b0ffb 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/cases.json +++ b/scripts/__tests__/fixtures/telemetry-bindings/cases.json @@ -44,6 +44,31 @@ "aiddTask": "dotted", "aiddDeclaredAt": "2026-10-09T09:00:00.000Z" } + }, + "stamped-for-this-branch": { + "branch": "feat/x", + "keys": { + "aiddTask": "stamped", + "aiddTicket": "PROJ-30", + "aiddDeclaredFor": "feat/x", + "aiddDeclaredAt": "2026-10-09T09:00:00.000Z" + } + }, + "stamped-for-another-branch": { + "branch": "feat/x", + "keys": { + "aiddTask": "copied", + "aiddTicket": "PROJ-31", + "aiddDeclaredFor": "feat/original", + "aiddDeclaredAt": "2026-10-09T09:00:00.000Z" + } + }, + "stamp-without-declared-at": { + "branch": "feat/x", + "keys": { + "aiddTask": "half-written", + "aiddDeclaredFor": "feat/x" + } } }, "binding": { @@ -187,7 +212,7 @@ "head": "refs/heads/feat/x", "originHead": null }, - "branch-task-without-declared-at-is-bound": { + "branch-task-without-declared-at-is-unbound": { "session_id": null, "at": "2026-10-09T15:00:00.000Z", "gitConfig": "task-without-declared-at", @@ -228,6 +253,34 @@ "gitConfig": null, "head": "refs/heads/main", "originHead": null + }, + "branch-stamped-for-itself-is-bound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "stamped-for-this-branch", + "head": "refs/heads/feat/x", + "originHead": null + }, + "branch-stamped-for-another-branch-is-unbound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "stamped-for-another-branch", + "head": "refs/heads/feat/x", + "originHead": null + }, + "branch-stamp-without-declared-at-is-unbound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "stamp-without-declared-at", + "head": "refs/heads/feat/x", + "originHead": null + }, + "branch-unstamped-legacy-declaration-is-bound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "task-and-ticket", + "head": "refs/heads/feat/x", + "originHead": null } }, "branchRole": { @@ -654,7 +707,12 @@ "lines": [ { "token": "t1", - "clone": { "path": "/work/a/.git", "dev": "16777231", "ino": "100", "birthtimeMs": 1760000000000 }, + "clone": { + "path": "/work/a/.git", + "dev": "16777231", + "ino": "100", + "birthtimeMs": 1760000000000 + }, "open": "2026-10-09T09:00:00.000Z" } ], @@ -664,7 +722,12 @@ "lines": [ { "token": "t1", - "clone": { "path": "/work/a/.git", "dev": "16777231", "ino": "100", "birthtimeMs": 1760000000000 }, + "clone": { + "path": "/work/a/.git", + "dev": "16777231", + "ino": "100", + "birthtimeMs": 1760000000000 + }, "open": "2026-10-09T09:00:00.000Z" } ], diff --git a/scripts/__tests__/fixtures/telemetry-bindings/expected.json b/scripts/__tests__/fixtures/telemetry-bindings/expected.json index de957dc4c..0e43ca3d0 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/expected.json +++ b/scripts/__tests__/fixtures/telemetry-bindings/expected.json @@ -1,4 +1,16 @@ { + "gitConfig": { + "task-and-ticket": "declared", + "task-only": "declared", + "none": "declared", + "none-empty-task": "declared", + "task-without-declared-at": "undeclared", + "undeclared": "undeclared", + "dotted-branch-name": "declared", + "stamped-for-this-branch": "declared", + "stamped-for-another-branch": "undeclared", + "stamp-without-declared-at": "undeclared" + }, "binding": { "session-declared-once": { "state": "bound", @@ -136,12 +148,8 @@ "ticket": null, "none": true }, - "branch-task-without-declared-at-is-bound": { - "state": "bound", - "source": "branch", - "task": "orphan", - "ticket": null, - "none": false + "branch-task-without-declared-at-is-unbound": { + "state": "unbound" }, "branch-undeclared-is-unbound": { "state": "unbound" @@ -165,6 +173,26 @@ "task": "checkout-fix", "ticket": "PROJ-12", "none": false + }, + "branch-stamped-for-itself-is-bound": { + "state": "bound", + "source": "branch", + "task": "stamped", + "ticket": "PROJ-30", + "none": false + }, + "branch-stamped-for-another-branch-is-unbound": { + "state": "unbound" + }, + "branch-stamp-without-declared-at-is-unbound": { + "state": "unbound" + }, + "branch-unstamped-legacy-declaration-is-bound": { + "state": "bound", + "source": "branch", + "task": "checkout-fix", + "ticket": "PROJ-12", + "none": false } }, "branchRole": { @@ -342,13 +370,23 @@ "torn-last-line-is-not-yet-written": { "damaged": false, "intervals": [ - { "token": "t1", "path": "/work/a/.git", "from": "2026-10-09T09:00:00.000Z", "to": null } + { + "token": "t1", + "path": "/work/a/.git", + "from": "2026-10-09T09:00:00.000Z", + "to": null + } ] }, "whole-event-without-its-newline-is-not-yet-written": { "damaged": false, "intervals": [ - { "token": "t1", "path": "/work/a/.git", "from": "2026-10-09T09:00:00.000Z", "to": null } + { + "token": "t1", + "path": "/work/a/.git", + "from": "2026-10-09T09:00:00.000Z", + "to": null + } ] }, "torn-line-with-a-line-after-it-damages": { From 3614315fc2392d837645e07f307956502fd1ea60 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 21:05:38 +0200 Subject: [PATCH 03/13] docs(telemetry): say what declares a branch, and that the gate holds every prompt The usage contract said the gate asks once per working branch; it holds every prompt until the branch is declared, as the spec that introduced it requires. The contract, the plugin README, the hook header and the shared fixture now state the branch declaration rule, the rename and legacy decisions, and the limits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 23 +++++++++++++++++-- cli/aidd_docs/memory/telemetry.md | 1 + plugins/aidd-telemetry/README.md | 2 +- plugins/aidd-telemetry/hooks/prompt-gate.cjs | 5 ++-- .../skills/01-usage/actions/answer.md | 2 +- .../fixtures/telemetry-bindings/README.md | 12 +++++++--- 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 54a4207c2..2efe210a0 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -153,7 +153,7 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | `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`, `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` `branch..aiddTask`, `aiddTicket`, `aiddDeclaredFor`, `aiddDeclaredAt` | `task` | hooks, `task`, `report` via snapshots | branch declaration, `aiddDeclaredAt` written last, `aiddDeclaredFor` = ``; see [Branch declaration](#branch-declaration) | | git config `--local` `aidd.telemetry` | `on`, `off`, `forget` | hooks, CLI | `2:` or `off`. Half of consent: alone it grants nothing | - A line not exactly its store's format is skipped, never guessed. Every JSON Lines store is append-only except the ledger. @@ -178,6 +178,21 @@ flowchart TD - A declaration of no task is `declared-none`. - Declaring a branch late moves its earlier calls onto the task. +- A branch snapshot with no `declared_at` attributes nothing. + +### Branch declaration + +A branch is declared only when the command declared it on that branch. Both readers, the CLI (`parseBranchConfig`) and the hook (`lib/git.cjs`), apply the same rule, pinned by the shared fixture. + +| git config of the branch read | Declared? | Why | +| --- | --- | --- | +| `aiddDeclaredAt` set, `aiddDeclaredFor` = this branch | yes | written by `task` on this branch | +| `aiddDeclaredAt` set, no `aiddDeclaredFor` | yes | written by cli-v5.5.0 or earlier, which did not stamp. Kept readable: reports and the gate do not change for data already written. Declaring again stamps it | +| `aiddDeclaredAt` set, `aiddDeclaredFor` = another branch | no | carried by `git branch -c` (copy) or `git branch -m` (rename), which move every `branch..*` key | +| `aiddTask` or `aiddTicket` without `aiddDeclaredAt` | no | set by hand, or a declaration cut short: `task` writes `aiddDeclaredAt` last | + +- **Rename** (`git branch -m A B`): B is undeclared, so the gate asks on B. One declaration on B covers B from its creation, since the reflog moves with the branch; calls made while it was A keep A's task. git moves the keys the same way for a copy and a rename, and telling them apart would mean reading reflog entries that expire: one question after a rename is the cost. +- **Copy** (`git branch -c A B`): B is undeclared until declared on B. - Sub-agent and advisor calls follow their parent session. ## Ask @@ -193,7 +208,8 @@ flowchart TD | CLI usable | `aidd` on `PATH` and knows `telemetry task` | - Anything else passes. A crash exits 0: the hook never fails a prompt. -- Asks once per working branch; switching to an unbound branch asks again. +- Re-checked on every prompt: each prompt on an undeclared working branch is blocked until the branch or the session is declared (`--none` included). A declared branch is never asked again; switching to an undeclared one asks. +- Why every prompt and not once: the gate exists so that no token of the work is spent before it has a task ([spec](../tasks/2026_10/2026_10_09_deterministic-tokens/spec.md), [target](../tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md)). "Once per working branch" there meant answered once and remembered; letting the next prompt through would spend tokens unattributed. This sentence said "asks once" until 2026-10-16; the hook never did. Answering never blocks and never reaches the model: - `! aidd telemetry task ...` in the terminal fires no `UserPromptSubmit`. @@ -304,6 +320,9 @@ None observable by the CLI. | Call read while git config, `.git` or git dir unreadable | `unreadable-consent`, offsets move on: not recovered on repair | - | | pid reused within one boot by a Claude process whose `startup` was never recorded (e.g. started before `on`) | can take the dead process's last session as predecessor, so `/clear` carries its task | announced, replaced by first declaration | | `AIDD_TELEMETRY=0` | stops hooks too; a key turned off meanwhile is observed at the next run without it | - | +| Every declaration key written by hand, `aiddDeclaredFor` and `aiddDeclaredAt` included | the branch reads as declared: no reader can tell who wrote a git config key | - | +| Copy of an unstamped (cli-v5.5.0) declaration | the copy inherits it | declare on the copy, or on the original to stamp it before copying | +| Branch snapshots recorded before stamping (a copied branch, a hand-set task with a `declared_at`) | keep attributing: attribution reads snapshots, `branches.jsonl` is append-only | - | ## Never leaves the machine diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index c59350692..ec6413e85 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -49,6 +49,7 @@ flowchart LR | Why a call is counted once | `domain/usage-fold.ts` | | Where a call's repository comes from | `application/directory-resolver.ts`, `domain/repository-identity.ts` | | Which task a call belongs to | `domain/report/attribution.ts`, `domain/declaration/binding-resolution.ts` | +| Whether a branch is declared: only by `task`, on that branch (`aiddDeclaredAt` set, `aiddDeclaredFor` absent or equal to the branch); the hook's twin is `plugins/aidd-telemetry/hooks/lib/git.cjs` | `domain/branch-binding.ts` `parseBranchConfig`; rule and limits in the [usage contract](../../../aidd_docs/product/usage-contract.md#branch-declaration) | | What counts as consent | `domain/telemetry-consent.ts`, `infrastructure/git-consent-adapter.ts` | | Whether a call is stored | `application/directory-resolver.ts` | | What a clone is | `domain/consent/clone-identity.ts` | diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index ff43f5592..b1f187686 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -26,7 +26,7 @@ flowchart LR 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`. 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. +3. Work. While the branch has no task, Claude Code holds each prompt and asks for one: send `aidd telemetry task [--ticket ]` (or `--none`) as your next prompt, or run it in a terminal. Once declared, the branch is never asked again. A declaration belongs to the branch it was made on: a branch copied or renamed with `git branch -c` or `-m` is asked again. 4. Read: `aidd telemetry report --axis task`. ## Commands diff --git a/plugins/aidd-telemetry/hooks/prompt-gate.cjs b/plugins/aidd-telemetry/hooks/prompt-gate.cjs index 2d1174944..5f10f80da 100644 --- a/plugins/aidd-telemetry/hooks/prompt-gate.cjs +++ b/plugins/aidd-telemetry/hooks/prompt-gate.cjs @@ -1,5 +1,6 @@ -// UserPromptSubmit: answers a typed declaration without the model, and asks for one, once -// per working branch, before any token is spent. Inert outside Claude, outside an opted-in +// UserPromptSubmit: answers a typed declaration without the model, and holds every prompt on +// an undeclared working branch until one is made, so no token is spent before the work has a +// task. A declared branch is never asked again. Inert outside Claude, outside an opted-in // project, and whenever nobody is there to answer. const { resolveAidd, runAidd } = require("./lib/aidd.cjs"); const { guardedContext } = require("./lib/context.cjs"); diff --git a/plugins/aidd-telemetry/skills/01-usage/actions/answer.md b/plugins/aidd-telemetry/skills/01-usage/actions/answer.md index 3f95f7f62..5c5e03b96 100644 --- a/plugins/aidd-telemetry/skills/01-usage/actions/answer.md +++ b/plugins/aidd-telemetry/skills/01-usage/actions/answer.md @@ -27,7 +27,7 @@ The figures the report holds for the question, each counter apart, followed by e - `coverage.records` of `0`: that no call is recorded for the period, and why that may be, from the reference. - `totals.records` of `0` with `coverage.records` above `0`: that a filter matched no call of the period, naming each as `filters` prints it, from the reference. 5. **Bound the past.** When the period starts before `coverage.oldest_transcript_at`, say that Claude Code may have removed older session files, so earlier figures can be incomplete. -6. **Offer the fix.** For work with no task, say it can be attributed afterwards by declaring the task from that branch with `aidd telemetry task [--ticket ]`. Declare nothing yourself. +6. **Offer the fix.** For work with no task, say it can be attributed afterwards by declaring the task from that branch with `aidd telemetry task [--ticket ]`. A branch renamed or copied since its declaration is undeclared under its new name, and is declared the same way. Declare nothing yourself. ## Test diff --git a/scripts/__tests__/fixtures/telemetry-bindings/README.md b/scripts/__tests__/fixtures/telemetry-bindings/README.md index 9fcf24e2d..ac7678df8 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/README.md +++ b/scripts/__tests__/fixtures/telemetry-bindings/README.md @@ -28,9 +28,13 @@ answer, or an answer with no case, is an error. - The CLI marks a declaration made on a user's behalf by a hook with its hidden option `--by hook-intercept`. A manual declaration is `command`. It is an option, not an environment variable: an exported variable would silently relabel every manual declaration. -- A branch declaration is three git config keys, written with `--local`: - `branch..aiddTask`, `branch..aiddTicket`, `branch..aiddDeclaredAt`. Declared - as "none" means `aiddDeclaredAt` is set and `aiddTask` is absent or empty. `git config +- A branch declaration is four git config keys, written with `--local`: + `branch..aiddTask`, `branch..aiddTicket`, `branch..aiddDeclaredFor` (the + branch name itself) and, last, `branch..aiddDeclaredAt`. A branch is declared only when + `aiddDeclaredAt` is set and `aiddDeclaredFor`, when present, equals the branch read: a task with + no declaration time, or a stamp naming another branch (carried by `git branch -c` or `-m`), + declares nothing. A declaration with no stamp predates it and still counts. Declared + as "none" means it is declared and `aiddTask` is absent or empty. `git config --get-regexp` prints the variable part of a key in lower case (`aiddtask`), while the branch name keeps its case; a branch name may hold dots, so only the known suffix is cut off. @@ -42,6 +46,8 @@ answer, or an answer with no case, is an error. own time, following chains, a cycle binding nothing), then the branch. A branch is read only when it is a working branch. A declaration made after an instant does not change the answer for that instant: earlier work keeps the earlier task. +- `gitConfig`: whether each set of keys, written under its `branch`, declares that branch: + `declared` or `undeclared`. - `branchRole`: `working`, `default` or `detached`. The default branch is the target of `refs/remotes/origin/HEAD`; when the repository has none, `main` and `master`. Only a working branch is ever bound. From ed999f81bab4b94638e93af544b32f5b24714f4d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 21:22:07 +0200 Subject: [PATCH 04/13] refactor(telemetry): say outright that a branch with no snapshot binds nothing Replaces a fallback to an empty list, whose only mutant was equivalent, and records the mutation and gate runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../evidence.md | 17 +++++++++++++++++ .../telemetry/domain/report/attribution.ts | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md index 8a0f8e78d..a8e99b9ce 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md @@ -104,3 +104,20 @@ Each mutation applied alone to the source, the named suite run, the file restore | H3 | hook: legacy rejected | same | 3: fixture via git, binding fixture, `a bound branch, session or carried session passes` | | H4 | hook regex without `declaredfor` | same | 3 | | H5 | hook: `branchDeclaration` returns what it read, declared or not | same | 3 | + +## Mutation (`pnpm test:mutation:telemetry`, under the lock) + +- Run 1: `Report: reports/mutation/telemetry/ (score 96.3, floor 96)`. On changed lines, one survivor: `attribution.ts` `?? []` to `?? ["Stryker was here"]`, equivalent (a string is no snapshot of any generation, so the branch still binds nothing). +- The `?? []` was replaced by an explicit `if (snapshots === undefined) return null;`, which leaves no equivalent mutant. +- Run 2: `score 96.3, floor 96`. Mutants on the lines this change touches: `branch-binding.ts` 17 killed, `attribution.ts` 12 killed, store, source and forget adapters 1 killed each. None survived. + +## Gates + +| Gate | Shortest decisive line | +| --- | --- | +| `cd cli && pnpm test` | `Test Files 511 passed \| 1 skipped (512)`, `Tests 6668 passed \| 1 skipped (6669)` | +| `node --test 'scripts/__tests__/*.test.js'` | `ℹ tests 459`, `ℹ fail 0` | +| bundle budget | before: `693.2 KB / budget: 695 KB`; after: `693.6 KB / budget: 695 KB`, `OK: within budget` | +| `pnpm smoke` (`XDG_CONFIG_HOME`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR` temp, `GIT_CONFIG_NOSYSTEM=1`; the script moves `HOME` and `AIDD_USER_CONFIG_DIR`) | `PASS: 132 FAIL: 0 SKIP: 1 · coverage 100%` | +| `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository` | +| same with `--attended` | `…; asked, answered by the hook, kept across /clear` | diff --git a/cli/src/contexts/telemetry/domain/report/attribution.ts b/cli/src/contexts/telemetry/domain/report/attribution.ts index a2d2db6d4..43ccae26e 100644 --- a/cli/src/contexts/telemetry/domain/report/attribution.ts +++ b/cli/src/contexts/telemetry/domain/report/attribution.ts @@ -140,7 +140,8 @@ function branchVerdict( facts: AttributionFacts ): Attribution | null { if (record.git_branch === null) return null; - const snapshots = facts.branches.get(snapshotKey(record.repository_id, record.git_branch)) ?? []; + const snapshots = facts.branches.get(snapshotKey(record.repository_id, record.git_branch)); + if (snapshots === undefined) return null; const branch = generationAt(snapshots.filter(isDeclared), at); if (branch === undefined || (branch.task === null && !branch.none)) return null; return verdict(branch.task, branch.ticket); From 703b646efbe8f2f13115294f6590dd45705bed3a Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 21:33:22 +0200 Subject: [PATCH 05/13] fix(telemetry): declare again over a branch copied onto with -C git branch -C or -M onto an existing branch leaves each declaration key twice, and a plain git config set refuses such a key, so the command could no longer declare that branch. The store now replaces or unsets every value. Also from review round 1: the rename e2e checks a call made on the renamed branch, the architecture doc no longer says the gate asks once, the shared fixture keeps its formatting, and conditions made always true by the new rule are dropped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 5 +- .../contract.md | 1 + .../domain/declaration/binding-resolution.ts | 5 +- .../telemetry/domain/report/attribution.ts | 2 +- .../git-branch-binding-store-adapter.ts | 9 +-- .../forget/repository-declarations-adapter.ts | 2 +- .../domain/binding-resolution.unit.test.ts | 3 +- ...-binding-store-adapter.integration.test.ts | 25 +++++++ .../telemetry-report-declaration.e2e.test.ts | 22 ++++-- cli/tests/e2e/telemetry-report-sandbox.ts | 7 +- docs/ARCHITECTURE.md | 2 +- .../fixtures/telemetry-bindings/cases.json | 70 ++++++++----------- .../fixtures/telemetry-bindings/expected.json | 48 +++++-------- 13 files changed, 113 insertions(+), 88 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 2efe210a0..557c6b983 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -179,6 +179,7 @@ flowchart TD - A declaration of no task is `declared-none`. - Declaring a branch late moves its earlier calls onto the task. - A branch snapshot with no `declared_at` attributes nothing. +- Sub-agent and advisor calls follow their parent session. ### Branch declaration @@ -193,7 +194,7 @@ A branch is declared only when the command declared it on that branch. Both read - **Rename** (`git branch -m A B`): B is undeclared, so the gate asks on B. One declaration on B covers B from its creation, since the reflog moves with the branch; calls made while it was A keep A's task. git moves the keys the same way for a copy and a rename, and telling them apart would mean reading reflog entries that expire: one question after a rename is the cost. - **Copy** (`git branch -c A B`): B is undeclared until declared on B. -- Sub-agent and advisor calls follow their parent session. +- **Copy or rename onto an existing branch** (`-C`, `-M`): git keeps both sets of keys, the copied ones first. The branch's own values, listed last, are read; declaring again replaces every value. ## Ask @@ -209,7 +210,7 @@ A branch is declared only when the command declared it on that branch. Both read - Anything else passes. A crash exits 0: the hook never fails a prompt. - Re-checked on every prompt: each prompt on an undeclared working branch is blocked until the branch or the session is declared (`--none` included). A declared branch is never asked again; switching to an undeclared one asks. -- Why every prompt and not once: the gate exists so that no token of the work is spent before it has a task ([spec](../tasks/2026_10/2026_10_09_deterministic-tokens/spec.md), [target](../tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md)). "Once per working branch" there meant answered once and remembered; letting the next prompt through would spend tokens unattributed. This sentence said "asks once" until 2026-10-16; the hook never did. +- Why every prompt and not once: the gate exists so that no token of the work is spent before it has a task ([spec](../tasks/2026_10/2026_10_09_deterministic-tokens/spec.md), [target](../tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md)). "Once per working branch" there meant answered once and remembered; letting the next prompt through would spend tokens unattributed. Answering never blocks and never reaches the model: - `! aidd telemetry task ...` in the terminal fires no `UserPromptSubmit`. diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md index f16a36dd7..5e3368f13 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md @@ -24,6 +24,7 @@ The product rule ([usage-contract](../../../product/usage-contract.md#attributio | 4 | `git branch -m A B` leaves B undeclared: the gate asks on B, and one declaration on B covers it again from its creation. Calls made on A keep A's task. | git moves the config keys the same way for a copy and a rename; telling them apart means reading the reflog, whose entries expire. The cost is one question after a rename, and nothing is lost: the reflog moves with the branch, so B's first declaration still covers B from A's creation. This replaces "follows a rename" in the usage contract. | | 5 | A branch snapshot with `declared_at: null` attributes nothing. | Such a snapshot comes only from keys set by hand or a declaration cut short: every release wrote `aiddDeclaredAt`. | | 6 | M8: the doc is wrong, not the hook. The gate blocks every prompt on an undeclared working branch until it is declared (or declared `--none`). A declared branch is never asked again. | The spec that introduced the gate (`2026_10_09_deterministic-tokens/spec.md`) requires asking "before any token of that work is spent", and its target (`context/target.md`, "When to ask") re-checks every prompt. "Once" meant "remembered once answered". Letting later prompts through would spend tokens unattributed and need per-branch state. | +| 7 | The command replaces every value of a key (`--replace-all`, `--unset-all`). Added after review round 1. | `git branch -C` or `-M` onto an existing branch leaves each key twice, copied values first; the branch's own values, listed last, are read, and a plain `git config` set refuses a key with two values, so declaring again failed. | ## Limits (stated, not fixed) diff --git a/cli/src/contexts/telemetry/domain/declaration/binding-resolution.ts b/cli/src/contexts/telemetry/domain/declaration/binding-resolution.ts index 358c0ad27..55bc80780 100644 --- a/cli/src/contexts/telemetry/domain/declaration/binding-resolution.ts +++ b/cli/src/contexts/telemetry/domain/declaration/binding-resolution.ts @@ -24,7 +24,8 @@ export interface BindingFacts { readonly at: Date; readonly declarations: readonly SessionDeclaration[]; readonly carries: readonly SessionCarry[]; - /** The declaration of the branch being worked on, `null` when it is not a working branch. */ + /** The declaration of the branch being worked on, as `parseBranchConfig` keeps it: `null` + * when it is not a working branch or the command did not declare it. */ readonly branch: BranchConfigBinding | null; } @@ -95,7 +96,7 @@ export function resolveBinding(facts: BindingFacts): Binding { if (session !== null) return session; } const branch = facts.branch; - if (branch !== null && (branch.task !== null || branch.none)) { + if (branch !== null) { return { state: "bound", source: "branch", diff --git a/cli/src/contexts/telemetry/domain/report/attribution.ts b/cli/src/contexts/telemetry/domain/report/attribution.ts index 43ccae26e..0f9261235 100644 --- a/cli/src/contexts/telemetry/domain/report/attribution.ts +++ b/cli/src/contexts/telemetry/domain/report/attribution.ts @@ -143,7 +143,7 @@ function branchVerdict( const snapshots = facts.branches.get(snapshotKey(record.repository_id, record.git_branch)); if (snapshots === undefined) return null; const branch = generationAt(snapshots.filter(isDeclared), at); - if (branch === undefined || (branch.task === null && !branch.none)) return null; + if (branch === undefined) return null; return verdict(branch.task, branch.ticket); } diff --git a/cli/src/contexts/telemetry/infrastructure/declaration/git-branch-binding-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/declaration/git-branch-binding-store-adapter.ts index d2e6552fb..ea3988440 100644 --- a/cli/src/contexts/telemetry/infrastructure/declaration/git-branch-binding-store-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/declaration/git-branch-binding-store-adapter.ts @@ -3,7 +3,7 @@ import type { BranchBindingStore, BranchHeads } from "../../domain/ports/branch- import { runGit } from "../run-git.js"; const NOT_SYMBOLIC = 1; -/** `git config --unset` of a key that is not there. */ +/** `git config --unset-all` of a key that is not there. */ const KEY_ABSENT = 5; /** Keeps a branch's declaration in the repository's own git config: `branch..aiddTask`, @@ -39,11 +39,12 @@ export class GitBranchBindingStoreAdapter implements BranchBindingStore { private setOrUnset(root: string, branch: string, variable: string, value: string | null): void { const key = `branch.${branch}.${variable}`; - // After `--`, so a value that starts with a dash is never read as an option. + // After `--`, so a value that starts with a dash is never read as an option. `-all`: a + // branch copied onto with `git branch -C` holds every key twice, and a plain set refuses. const args = value === null - ? ["config", "--local", "--unset", "--", key] - : ["config", "--local", "--", key, value]; + ? ["config", "--local", "--unset-all", "--", key] + : ["config", "--local", "--replace-all", "--", key, value]; const run = runGit(this.env, root, args); if (run.status === 0 || (value === null && run.status === KEY_ABSENT)) return; throw new Error(`git config ${key} failed in ${root}`); diff --git a/cli/src/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.ts b/cli/src/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.ts index 59c1dc892..afe7db4e9 100644 --- a/cli/src/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.ts @@ -11,7 +11,7 @@ import { runGit } from "../run-git.js"; const NO_MATCH = 1; /** `git config --unset` of a key that is not there. */ const KEY_ABSENT = 5; -/** The three keys a declaration writes, as git lists them: the variable in lower case, the +/** The four keys a declaration writes, as git lists them: the variable in lower case, the * branch name, which may hold dots, kept as it is. */ const TASK_KEYS = "^branch\\..+\\.(aiddtask|aiddticket|aidddeclaredfor|aidddeclaredat)$"; diff --git a/cli/tests/contexts/telemetry/domain/binding-resolution.unit.test.ts b/cli/tests/contexts/telemetry/domain/binding-resolution.unit.test.ts index a9302d864..aad53d6e6 100644 --- a/cli/tests/contexts/telemetry/domain/binding-resolution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/binding-resolution.unit.test.ts @@ -170,8 +170,7 @@ describe("a branch", () => { }); }); - it("is unbound when it declares nothing, or when it is not a working branch", () => { - expect(resolveBinding(facts(branch(null)))).toEqual({ state: "unbound" }); + it("is unbound when no declared branch is given: parseBranchConfig keeps only declared ones", () => { expect(resolveBinding(facts(null))).toEqual({ state: "unbound" }); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts index c8a71d8e3..7212cf5ce 100644 --- a/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts @@ -93,6 +93,31 @@ describe("declaring on a branch", () => { ).toEqual([["feat/renamed", "again"]]); }); + it("declares again over a branch copied onto with -C, which leaves every key twice", async () => { + await store.declare(repo, "feat/x", task("theirs", "PROJ-1")); + git(repo, env, "branch", "feat/y"); + await store.declare(repo, "feat/y", task("ours", "PROJ-2")); + git(repo, env, "branch", "-C", "feat/x", "feat/y"); + // git puts the copied keys first, so the branch's own values, listed last, are read. + expect( + (await new GitBranchBindingSourceAdapter(env).bindings(repo)).map((b) => [b.branch, b.task]) + ).toEqual([ + ["feat/x", "theirs"], + ["feat/y", "ours"], + ]); + await store.declare(repo, "feat/y", task("again")); + expect( + (await new GitBranchBindingSourceAdapter(env).bindings(repo)).map((b) => [ + b.branch, + b.task, + b.ticket, + ]) + ).toEqual([ + ["feat/x", "theirs", "PROJ-1"], + ["feat/y", "again", null], + ]); + }); + it("drops a ticket the new declaration does not name", async () => { await store.declare(repo, "feat/x", task("first", "PROJ-12")); await store.declare(repo, "feat/x", task("second")); diff --git a/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts b/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts index ced3f2d8c..d6e340f95 100644 --- a/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts +++ b/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { git } from "../helpers/git-sandbox.js"; import { + addCall, aidd, declareBranch, json, rowsOf, + S_BRANCH, sandbox, useReportSandbox, } from "./telemetry-report-sandbox.js"; @@ -32,18 +34,28 @@ describe("a branch is bound only by a declaration the command made on it", () => expect(tasks["no-binding"]?.records).toBe(3); }); - it("asks again after a rename, and one declaration covers the renamed branch", async () => { + it("asks again after a rename, and one declaration on the new name covers it", async () => { await declareBranch(); - gitHere("branch", "-D", "feat/b"); - gitHere("branch", "-m", "feat/a", "feat/b"); + gitHere("branch", "-m", "feat/a", "feat/c"); + await addCall({ + id: "c1", + session: S_BRANCH, + at: "2026-10-08T12:00:00.000Z", + branch: "feat/c", + model: "m-big", + input: 1, + output: 1, + read: 1, + write: 1, + }); const renamed = rowsOf(await json("task")); expect(renamed.checkout?.records).toBe(2); - expect(renamed["no-binding"]?.records).toBe(1); + expect(renamed["no-binding"]?.records).toBe(2); expect((await aidd(["task", "after-rename"])).exitCode).toBe(0); const declared = rowsOf(await json("task")); expect(declared.checkout?.records).toBe(2); expect(declared["after-rename"]?.records).toBe(1); - expect(declared["no-binding"]).toBeUndefined(); + expect(declared["no-binding"]?.records).toBe(1); }); it("still reads a declaration written before the branch was stamped", async () => { diff --git a/cli/tests/e2e/telemetry-report-sandbox.ts b/cli/tests/e2e/telemetry-report-sandbox.ts index 832e6493b..9aff5b0af 100644 --- a/cli/tests/e2e/telemetry-report-sandbox.ts +++ b/cli/tests/e2e/telemetry-report-sandbox.ts @@ -1,4 +1,4 @@ -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { afterEach, beforeEach, expect } from "vitest"; import { git, initRepository, sandboxGitEnv } from "../helpers/git-sandbox.js"; @@ -271,6 +271,11 @@ export function useReportSandbox(): void { }); } +/** Appends a call to the sandbox's main transcript, as Claude would write it later. */ +export async function addCall(call: Call): Promise { + await appendFile(join(claude, "projects", "sandbox", "main.jsonl"), `${usageLine(call, repo)}\n`); +} + /** Every file under the telemetry directory with its bytes: what a run changed shows. */ export async function held(): Promise> { const found: Record = {}; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3db1a9ebd..4e508fe37 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -115,7 +115,7 @@ Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on ` | Plugin | Event | Runs | Purpose | | ---------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------- | | `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | -| `aidd-telemetry` | `SessionStart` · `UserPromptSubmit` | `hooks/prompt-gate.cjs` | Ask for a task once per branch, answer it without the model, keep it across `/clear`, catch the ledger up | +| `aidd-telemetry` | `SessionStart` · `UserPromptSubmit` | `hooks/prompt-gate.cjs` | Hold prompts on an undeclared branch until a task is declared, answer it without the model, keep it across `/clear`, catch the ledger up | ## Portability diff --git a/scripts/__tests__/fixtures/telemetry-bindings/cases.json b/scripts/__tests__/fixtures/telemetry-bindings/cases.json index 2fa9b0ffb..544e1335d 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/cases.json +++ b/scripts/__tests__/fixtures/telemetry-bindings/cases.json @@ -219,6 +219,34 @@ "head": "refs/heads/feat/x", "originHead": null }, + "branch-stamped-for-itself-is-bound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "stamped-for-this-branch", + "head": "refs/heads/feat/x", + "originHead": null + }, + "branch-stamped-for-another-branch-is-unbound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "stamped-for-another-branch", + "head": "refs/heads/feat/x", + "originHead": null + }, + "branch-stamp-without-declared-at-is-unbound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "stamp-without-declared-at", + "head": "refs/heads/feat/x", + "originHead": null + }, + "branch-unstamped-legacy-declaration-is-bound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "task-and-ticket", + "head": "refs/heads/feat/x", + "originHead": null + }, "branch-undeclared-is-unbound": { "session_id": null, "at": "2026-10-09T15:00:00.000Z", @@ -253,34 +281,6 @@ "gitConfig": null, "head": "refs/heads/main", "originHead": null - }, - "branch-stamped-for-itself-is-bound": { - "session_id": null, - "at": "2026-10-09T15:00:00.000Z", - "gitConfig": "stamped-for-this-branch", - "head": "refs/heads/feat/x", - "originHead": null - }, - "branch-stamped-for-another-branch-is-unbound": { - "session_id": null, - "at": "2026-10-09T15:00:00.000Z", - "gitConfig": "stamped-for-another-branch", - "head": "refs/heads/feat/x", - "originHead": null - }, - "branch-stamp-without-declared-at-is-unbound": { - "session_id": null, - "at": "2026-10-09T15:00:00.000Z", - "gitConfig": "stamp-without-declared-at", - "head": "refs/heads/feat/x", - "originHead": null - }, - "branch-unstamped-legacy-declaration-is-bound": { - "session_id": null, - "at": "2026-10-09T15:00:00.000Z", - "gitConfig": "task-and-ticket", - "head": "refs/heads/feat/x", - "originHead": null } }, "branchRole": { @@ -707,12 +707,7 @@ "lines": [ { "token": "t1", - "clone": { - "path": "/work/a/.git", - "dev": "16777231", - "ino": "100", - "birthtimeMs": 1760000000000 - }, + "clone": { "path": "/work/a/.git", "dev": "16777231", "ino": "100", "birthtimeMs": 1760000000000 }, "open": "2026-10-09T09:00:00.000Z" } ], @@ -722,12 +717,7 @@ "lines": [ { "token": "t1", - "clone": { - "path": "/work/a/.git", - "dev": "16777231", - "ino": "100", - "birthtimeMs": 1760000000000 - }, + "clone": { "path": "/work/a/.git", "dev": "16777231", "ino": "100", "birthtimeMs": 1760000000000 }, "open": "2026-10-09T09:00:00.000Z" } ], diff --git a/scripts/__tests__/fixtures/telemetry-bindings/expected.json b/scripts/__tests__/fixtures/telemetry-bindings/expected.json index 0e43ca3d0..8b48ebe02 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/expected.json +++ b/scripts/__tests__/fixtures/telemetry-bindings/expected.json @@ -151,45 +151,45 @@ "branch-task-without-declared-at-is-unbound": { "state": "unbound" }, - "branch-undeclared-is-unbound": { - "state": "unbound" - }, - "branch-with-dots-in-its-name": { + "branch-stamped-for-itself-is-bound": { "state": "bound", "source": "branch", - "task": "dotted", - "ticket": null, + "task": "stamped", + "ticket": "PROJ-30", "none": false }, - "default-branch-never-reads-its-config": { + "branch-stamped-for-another-branch-is-unbound": { "state": "unbound" }, - "detached-head-never-reads-config": { + "branch-stamp-without-declared-at-is-unbound": { "state": "unbound" }, - "default-branch-still-has-its-session": { + "branch-unstamped-legacy-declaration-is-bound": { "state": "bound", - "source": "session-declared", + "source": "branch", "task": "checkout-fix", "ticket": "PROJ-12", "none": false }, - "branch-stamped-for-itself-is-bound": { + "branch-undeclared-is-unbound": { + "state": "unbound" + }, + "branch-with-dots-in-its-name": { "state": "bound", "source": "branch", - "task": "stamped", - "ticket": "PROJ-30", + "task": "dotted", + "ticket": null, "none": false }, - "branch-stamped-for-another-branch-is-unbound": { + "default-branch-never-reads-its-config": { "state": "unbound" }, - "branch-stamp-without-declared-at-is-unbound": { + "detached-head-never-reads-config": { "state": "unbound" }, - "branch-unstamped-legacy-declaration-is-bound": { + "default-branch-still-has-its-session": { "state": "bound", - "source": "branch", + "source": "session-declared", "task": "checkout-fix", "ticket": "PROJ-12", "none": false @@ -370,23 +370,13 @@ "torn-last-line-is-not-yet-written": { "damaged": false, "intervals": [ - { - "token": "t1", - "path": "/work/a/.git", - "from": "2026-10-09T09:00:00.000Z", - "to": null - } + { "token": "t1", "path": "/work/a/.git", "from": "2026-10-09T09:00:00.000Z", "to": null } ] }, "whole-event-without-its-newline-is-not-yet-written": { "damaged": false, "intervals": [ - { - "token": "t1", - "path": "/work/a/.git", - "from": "2026-10-09T09:00:00.000Z", - "to": null - } + { "token": "t1", "path": "/work/a/.git", "from": "2026-10-09T09:00:00.000Z", "to": null } ] }, "torn-line-with-a-line-after-it-damages": { From fd7a31d61207f4a6a155511bf47c5e526000a230 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 21:44:26 +0200 Subject: [PATCH 06/13] docs(telemetry): record review round 1 and the gates it reran Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../evidence.md | 7 ++++++ .../review.md | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md index a8e99b9ce..63bc89e68 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md @@ -121,3 +121,10 @@ Each mutation applied alone to the source, the named suite run, the file restore | `pnpm smoke` (`XDG_CONFIG_HOME`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR` temp, `GIT_CONFIG_NOSYSTEM=1`; the script moves `HOME` and `AIDD_USER_CONFIG_DIR`) | `PASS: 132 FAIL: 0 SKIP: 1 · coverage 100%` | | `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository` | | same with `--attended` | `…; asked, answered by the hook, kept across /clear` | + +## Review round 1 fixes + +- Red first, `git-branch-binding-store-adapter.integration.test.ts`: `× declares again over a branch copied onto with -C, which leaves every key twice`, `Error: git config branch.feat/y.aiddTask failed`. Green after `--replace-all` / `--unset-all`: `Tests 13 passed (13)`. +- Probe: after `git branch -C x y` the config holds `[branch "y"]` twice, the copied section first; both readers keep the last value of a key, so `y` reads as its own declaration. +- Guards: plain set (S1) and plain unset (S2) each turn `declares again over a branch copied onto with -C…` red; the stamp mutation (M2) turns `asks again after a rename, and one declaration on the new name covers it` red (now checked on a call made on the renamed branch, `feat/c`). +- Gates on 703b646e: `cd cli && pnpm test` `Tests 6669 passed | 1 skipped (6670)`; `node --test 'scripts/__tests__/*.test.js'` `ℹ tests 459`, `ℹ fail 0`; bundle `693.5 KB / budget: 695 KB`; mutation `score 96.5, floor 96`, changed lines: `branch-binding.ts` 17, `binding-resolution.ts` 4, `attribution.ts` 15, store adapter 11, source and forget adapters 1 each, all killed; smoke `PASS: 132 FAIL: 0 SKIP: 1`; tokens end to end and `--attended`: same lines as above. diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md new file mode 100644 index 000000000..e5ddcd38b --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md @@ -0,0 +1,22 @@ +# Review: A declaration comes only from the command + +- Started: 2026-10-16 +- Reviewer: a fresh `aidd-dev:checker` agent per round, read-only, sandboxed runs, no mutation run (recorded in `evidence.md`) + +## Round 1 + +- Diff: `origin/next...ed999f81` +- Verdict: request changes, 7.5/10 + +| # | Class | Finding | Resolution (703b646e) | +| --- | --- | --- | --- | +| 1 | deal breaker | `docs/ARCHITECTURE.md:118` still said "Ask for a task once per branch" | reworded to the gate's real behaviour | +| 2 | suggestion | forget adapter comment said "three keys" | "four keys" | +| 3 | suggestion | usage contract: "Sub-agent and advisor calls…" fell under the new `Branch declaration` heading | moved back under Attribution | +| 4 | suggestion | change history sentence inside the contract | removed | +| 5 | suggestion | rename e2e proved coverage with a deleted branch's call | rewritten: rename `feat/a` to `feat/c`, a call made on `feat/c` is `no-binding`, then attributed by one declaration | +| 6 | suggestion | `-C`/`-M` onto a declared branch leaves keys twice; redeclaring fails | store uses `--replace-all` / `--unset-all`, red test first; rule written in the usage contract and as contract decision 7 | +| 7 | suggestion | always-true conditions left in `binding-resolution.ts` and `attribution.ts` | dropped; `BranchConfigBinding.declared_at` stays nullable because `BranchSnapshot` extends it and stored snapshots can hold `null` | +| 8 | suggestion | fixture JSON reformatted unrelated entries | fixture re-applied as text insertions; diff now only the new cases | + +Acceptance after round 1: 1, 2, 4, 5, 6 fulfilled; 3 and 8 partial (findings 5, 1, 3); 7 not re-run by the checker. From 0c5eab26a7fd592bfdf7a7fa85f323756094733e Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 21:58:19 +0200 Subject: [PATCH 07/13] fix(telemetry): a branch whose declaration keys are listed twice is undeclared git branch -C or -M onto a branch that already had declaration keys keeps both sets. Reading the last value of each mixed the copied declaration into the branch's own: a task or ticket the own declaration never set came from the copy, and its stamp passed. Both readers now treat any declaration key listed twice as no declaration; declaring again replaces every value. The shared fixture can now list keys in order, so both readers are pinned on it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 3 ++- .../2026_10_09_deterministic-tokens/spec.md | 3 +++ .../contract.md | 2 +- .../evidence.md | 2 +- .../telemetry/domain/branch-binding.ts | 17 +++++++++--- .../domain/branch-binding.unit.test.ts | 21 +++++++++++++++ ...y-declarations-adapter.integration.test.ts | 5 ++-- ...-binding-store-adapter.integration.test.ts | 9 +++---- .../telemetry-bindings-fixture.unit.test.ts | 5 ++-- .../telemetry-report-declaration.e2e.test.ts | 12 +++++++++ plugins/aidd-telemetry/README.md | 2 +- plugins/aidd-telemetry/hooks/lib/git.cjs | 11 +++++--- .../skills/01-usage/actions/answer.md | 2 +- .../__tests__/aidd-telemetry-hooks.test.js | 23 +++++++++++++--- .../fixtures/telemetry-bindings/cases.json | 27 +++++++++++++++++++ .../fixtures/telemetry-bindings/expected.json | 7 ++++- 16 files changed, 125 insertions(+), 26 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 557c6b983..688f0db6a 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -191,10 +191,11 @@ A branch is declared only when the command declared it on that branch. Both read | `aiddDeclaredAt` set, no `aiddDeclaredFor` | yes | written by cli-v5.5.0 or earlier, which did not stamp. Kept readable: reports and the gate do not change for data already written. Declaring again stamps it | | `aiddDeclaredAt` set, `aiddDeclaredFor` = another branch | no | carried by `git branch -c` (copy) or `git branch -m` (rename), which move every `branch..*` key | | `aiddTask` or `aiddTicket` without `aiddDeclaredAt` | no | set by hand, or a declaration cut short: `task` writes `aiddDeclaredAt` last | +| any declaration key listed twice | no | left by `git branch -C` or `-M` onto a branch that had keys: the copied declaration and the branch's own are mixed | - **Rename** (`git branch -m A B`): B is undeclared, so the gate asks on B. One declaration on B covers B from its creation, since the reflog moves with the branch; calls made while it was A keep A's task. git moves the keys the same way for a copy and a rename, and telling them apart would mean reading reflog entries that expire: one question after a rename is the cost. - **Copy** (`git branch -c A B`): B is undeclared until declared on B. -- **Copy or rename onto an existing branch** (`-C`, `-M`): git keeps both sets of keys, the copied ones first. The branch's own values, listed last, are read; declaring again replaces every value. +- **Copy or rename onto an existing branch** (`-C`, `-M`): git keeps both sets of keys, so some are listed twice and the two declarations mix. A branch with any declaration key listed twice is undeclared; declaring again replaces every value. ## Ask diff --git a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md index ed0382b0b..3f89444a6 100644 --- a/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md +++ b/aidd_docs/tasks/2026_10/2026_10_09_deterministic-tokens/spec.md @@ -39,6 +39,9 @@ coming only from what a person declared, with the previous telemetry removed ent > **Deviation, 2026-10-12, maintainer decision.** IDE extensions are not asked: VS Code shows > no hook message, so a block would eat the prompt. Declare in a terminal; it binds the branch. > Evidence: [target.md](context/target.md). + > **Reading, 2026-10-16.** "At most once" means answered once and remembered for the branch: + > the gate holds every prompt until then, as "before any token of that work is spent" requires. + > See the usage contract, section Ask. - A session started by `/clear` keeps the task of the session it replaced, says so to the person, and a later declaration in it re-attributes the whole session. - Work with no declared task is its own row, with the reason it has none, and is attributed diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md index 5e3368f13..cc1313d48 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md @@ -24,7 +24,7 @@ The product rule ([usage-contract](../../../product/usage-contract.md#attributio | 4 | `git branch -m A B` leaves B undeclared: the gate asks on B, and one declaration on B covers it again from its creation. Calls made on A keep A's task. | git moves the config keys the same way for a copy and a rename; telling them apart means reading the reflog, whose entries expire. The cost is one question after a rename, and nothing is lost: the reflog moves with the branch, so B's first declaration still covers B from A's creation. This replaces "follows a rename" in the usage contract. | | 5 | A branch snapshot with `declared_at: null` attributes nothing. | Such a snapshot comes only from keys set by hand or a declaration cut short: every release wrote `aiddDeclaredAt`. | | 6 | M8: the doc is wrong, not the hook. The gate blocks every prompt on an undeclared working branch until it is declared (or declared `--none`). A declared branch is never asked again. | The spec that introduced the gate (`2026_10_09_deterministic-tokens/spec.md`) requires asking "before any token of that work is spent", and its target (`context/target.md`, "When to ask") re-checks every prompt. "Once" meant "remembered once answered". Letting later prompts through would spend tokens unattributed and need per-branch state. | -| 7 | The command replaces every value of a key (`--replace-all`, `--unset-all`). Added after review round 1. | `git branch -C` or `-M` onto an existing branch leaves each key twice, copied values first; the branch's own values, listed last, are read, and a plain `git config` set refuses a key with two values, so declaring again failed. | +| 7 | A branch with any declaration key listed twice is undeclared, and the command replaces every value of a key (`--replace-all`, `--unset-all`). Added after review rounds 1 and 2. | `git branch -C` or `-M` onto an existing branch keeps both sets of keys: reading the last value of each mixed the copied declaration into the branch's own (a ticket or task the own one never set), and a plain `git config` set refuses a key with two values, so declaring again failed. | ## Limits (stated, not fixed) diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md index 63bc89e68..3b83625ee 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md @@ -125,6 +125,6 @@ Each mutation applied alone to the source, the named suite run, the file restore ## Review round 1 fixes - Red first, `git-branch-binding-store-adapter.integration.test.ts`: `× declares again over a branch copied onto with -C, which leaves every key twice`, `Error: git config branch.feat/y.aiddTask failed`. Green after `--replace-all` / `--unset-all`: `Tests 13 passed (13)`. -- Probe: after `git branch -C x y` the config holds `[branch "y"]` twice, the copied section first; both readers keep the last value of a key, so `y` reads as its own declaration. +- Probe: after `git branch -C x y` the config holds `[branch "y"]` twice, the copied section first; both readers kept the last value of a key. Round 2 showed this mixes the two declarations when the branch's own left a key unset (see below). - Guards: plain set (S1) and plain unset (S2) each turn `declares again over a branch copied onto with -C…` red; the stamp mutation (M2) turns `asks again after a rename, and one declaration on the new name covers it` red (now checked on a call made on the renamed branch, `feat/c`). - Gates on 703b646e: `cd cli && pnpm test` `Tests 6669 passed | 1 skipped (6670)`; `node --test 'scripts/__tests__/*.test.js'` `ℹ tests 459`, `ℹ fail 0`; bundle `693.5 KB / budget: 695 KB`; mutation `score 96.5, floor 96`, changed lines: `branch-binding.ts` 17, `binding-resolution.ts` 4, `attribution.ts` 15, store adapter 11, source and forget adapters 1 each, all killed; smoke `PASS: 132 FAIL: 0 SKIP: 1`; tokens end to end and `--attended`: same lines as above. diff --git a/cli/src/contexts/telemetry/domain/branch-binding.ts b/cli/src/contexts/telemetry/domain/branch-binding.ts index cf7bc9122..e962170ba 100644 --- a/cli/src/contexts/telemetry/domain/branch-binding.ts +++ b/cli/src/contexts/telemetry/domain/branch-binding.ts @@ -41,13 +41,20 @@ interface DeclaredFields { /** The branch the command declared on: a copy or a rename moves the keys, not this name. */ declared_for: string | null; declared_at: string | null; + /** The keys read so far: one read again was left twice by `git branch -C` or `-M`. */ + seen: Set; + listedTwice: boolean; } /** Only the command declares, and it writes `aiddDeclaredAt` last: a key without it was set by * hand or cut short. A stamp naming another branch was carried here by `git branch -c` or - * `-m`. A declaration with no stamp was written before stamping existed and still counts. */ + * `-m`. A key listed twice mixes the declaration copied by `-C` or `-M` with the branch's own: + * neither is whole. A declaration with no stamp was written before stamping existed and still + * counts. */ function declaredByTheCommand(branch: string, fields: DeclaredFields): boolean { - return fields.declared_at !== null && (fields.declared_for ?? branch) === branch; + return ( + !fields.listedTwice && fields.declared_at !== null && (fields.declared_for ?? branch) === branch + ); } function textOrNull(value: string): string | null { @@ -66,7 +73,7 @@ export function parseBranchConfig(output: string): BranchConfigBinding[] { const key = newline === -1 ? entry : entry.slice(0, newline); const value = newline === -1 ? "" : entry.slice(newline + 1); const lower = key.toLowerCase(); - for (const [field, suffix] of Object.entries(FIELDS) as [keyof DeclaredFields, string][]) { + for (const [field, suffix] of Object.entries(FIELDS) as [keyof typeof FIELDS, string][]) { if (!lower.endsWith(suffix)) continue; const branch = key.slice(PREFIX.length, key.length - suffix.length); const held = branches.get(branch) ?? { @@ -74,7 +81,11 @@ export function parseBranchConfig(output: string): BranchConfigBinding[] { ticket: null, declared_for: null, declared_at: null, + seen: new Set(), + listedTwice: false, }; + held.listedTwice ||= held.seen.has(field); + held.seen.add(field); held[field] = textOrNull(value); branches.set(branch, held); } diff --git a/cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts b/cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts index b89d90aad..5758753c1 100644 --- a/cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/branch-binding.unit.test.ts @@ -123,6 +123,27 @@ describe("only a declaration the command made on the branch counts", () => { expect(parseBranchConfig(out)).toEqual([]); }); + it("reads nothing from a branch copied onto with -C, which lists a key twice", () => { + const out = listing( + entry("branch.y.aiddtask", "theirs"), + entry("branch.y.aidddeclaredfor", "x"), + declaredAt("y"), + entry("branch.y.aidddeclaredfor", "y"), + declaredAt("y") + ); + expect(parseBranchConfig(out)).toEqual([]); + }); + + it("reads nothing from a branch whose task alone is listed twice", () => { + const out = listing( + entry("branch.y.aiddtask", "theirs"), + entry("branch.y.aiddtask", "ours"), + entry("branch.y.aidddeclaredfor", "y"), + declaredAt("y") + ); + expect(parseBranchConfig(out)).toEqual([]); + }); + it("still reads a declaration written before declarations were stamped", () => { const out = listing(entry("branch.x.aiddtask", "legacy"), declaredAt("x")); expect(parseBranchConfig(out)).toMatchObject([{ branch: "x", task: "legacy" }]); diff --git a/cli/tests/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.integration.test.ts index 23e05e3e4..e58d97448 100644 --- a/cli/tests/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/forget/repository-declarations-adapter.integration.test.ts @@ -35,18 +35,19 @@ describe("the declarations in a repository's git config", () => { }); }); - it("counts the three keys of every branch, dotted and upper-case names included", async () => { + it("counts the four keys of every branch, dotted and upper-case names included", async () => { config( "branch.main.aiddTask", "branch.feat/a.b.aiddTicket", "branch.Feat/X.aiddDeclaredAt", + "branch.Feat/X.aiddDeclaredFor", "branch.main.remote", "branch.main.aiddOther", "aidd.task", "other.aiddTask" ); expect(await new RepositoryDeclarationsAdapter(env).count(clone)).toEqual({ - taskKeys: 3, + taskKeys: 4, consent: false, }); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts index 7212cf5ce..657cc1323 100644 --- a/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/git-branch-binding-store-adapter.integration.test.ts @@ -96,15 +96,12 @@ describe("declaring on a branch", () => { it("declares again over a branch copied onto with -C, which leaves every key twice", async () => { await store.declare(repo, "feat/x", task("theirs", "PROJ-1")); git(repo, env, "branch", "feat/y"); - await store.declare(repo, "feat/y", task("ours", "PROJ-2")); + await store.declare(repo, "feat/y", declarationOf({ kind: "none" }, at, "command")); git(repo, env, "branch", "-C", "feat/x", "feat/y"); - // git puts the copied keys first, so the branch's own values, listed last, are read. + // Both declarations are now mixed under feat/y: it is declared by neither. expect( (await new GitBranchBindingSourceAdapter(env).bindings(repo)).map((b) => [b.branch, b.task]) - ).toEqual([ - ["feat/x", "theirs"], - ["feat/y", "ours"], - ]); + ).toEqual([["feat/x", "theirs"]]); await store.declare(repo, "feat/y", task("again")); expect( (await new GitBranchBindingSourceAdapter(env).bindings(repo)).map((b) => [ diff --git a/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts b/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts index 85a80dbbe..b0dbc4dce 100644 --- a/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts +++ b/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts @@ -34,7 +34,8 @@ const lines = (name: string): string[] => interface GitConfigCase { branch: string; - keys: Record; + /** Pairs in order when a key is listed twice, as `git branch -C` leaves it. */ + keys: Record | [string, string][]; } interface BindingCase { session_id: string | null; @@ -105,7 +106,7 @@ const carries = () => lines("carries.jsonl").map((line) => parseSessionCarry(lin /** What `git config --local -z --get-regexp` prints for a case: the variable part of every * key in lower case, the branch name as it is. */ function gitConfigOutput(config: GitConfigCase): string { - return Object.entries(config.keys) + return (Array.isArray(config.keys) ? config.keys : Object.entries(config.keys)) .map(([variable, value]) => `branch.${config.branch}.${variable.toLowerCase()}\n${value}\0`) .join(""); } diff --git a/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts b/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts index d6e340f95..a796222ef 100644 --- a/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts +++ b/cli/tests/e2e/telemetry-report-declaration.e2e.test.ts @@ -58,6 +58,18 @@ describe("a branch is bound only by a declaration the command made on it", () => expect(declared["no-binding"]?.records).toBe(1); }); + it("does not mix a declaration copied with -C into the one the branch already had", async () => { + await declareBranch(); + gitHere("switch", "-q", "feat/b"); + expect((await aidd(["task", "--none"])).exitCode).toBe(0); + gitHere("switch", "-q", "feat/a"); + gitHere("branch", "-C", "feat/a", "feat/b"); + const tickets = rowsOf(await json("ticket")); + expect(tickets["PROJ-1"]?.records).toBe(2); + expect(tickets["declared-none"]?.records).toBeGreaterThanOrEqual(1); + expect(tickets["no-binding"]).toBeUndefined(); + }); + it("still reads a declaration written before the branch was stamped", async () => { gitHere("config", "--local", "branch.feat/b.aiddTask", "legacy"); gitHere("config", "--local", "branch.feat/b.aiddDeclaredAt", "2026-10-06T00:00:00.000Z"); diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index b1f187686..649750bd6 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -26,7 +26,7 @@ flowchart LR 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`. 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. While the branch has no task, Claude Code holds each prompt and asks for one: send `aidd telemetry task [--ticket ]` (or `--none`) as your next prompt, or run it in a terminal. Once declared, the branch is never asked again. A declaration belongs to the branch it was made on: a branch copied or renamed with `git branch -c` or `-m` is asked again. +3. Work. While the branch has no task, Claude Code holds each prompt and asks for one: send `aidd telemetry task [--ticket ]` (or `--none`) as your next prompt, or run it in a terminal. Once declared, the branch is never asked again. A declaration belongs to the branch it was made on: a branch copied or renamed with `git branch -c` or `-m` is asked again, unless the declaration was written by cli-v5.5.0 or earlier (see the usage contract's limits). 4. Read: `aidd telemetry report --axis task`. ## Commands diff --git a/plugins/aidd-telemetry/hooks/lib/git.cjs b/plugins/aidd-telemetry/hooks/lib/git.cjs index 7aab98aad..10fb16782 100644 --- a/plugins/aidd-telemetry/hooks/lib/git.cjs +++ b/plugins/aidd-telemetry/hooks/lib/git.cjs @@ -60,7 +60,8 @@ const FIELDS = { * a key in lower case and the branch name keeps its case; the branch name may hold dots, so * the known suffix is cut off the end. */ function parseBranchConfig(output, branch) { - const held = { task: null, ticket: null, declared_for: null, declared_at: null }; + const held = { task: null, ticket: null, declared_for: null, declared_at: null, listed_twice: false }; + const seen = new Set(); for (const entry of output.split("\0")) { if (!entry.startsWith("branch.")) continue; const newline = entry.indexOf("\n"); @@ -70,6 +71,8 @@ function parseBranchConfig(output, branch) { for (const [field, suffix] of Object.entries(FIELDS)) { if (!lower.endsWith(suffix)) continue; if (key.slice("branch.".length, key.length - suffix.length) === branch) { + held.listed_twice ||= seen.has(field); + seen.add(field); held[field] = value === "" ? null : value; } } @@ -79,9 +82,11 @@ function parseBranchConfig(output, branch) { /** Only the command declares, and it writes `aiddDeclaredAt` last: a key without it was set by * hand or cut short. A stamp naming another branch was carried here by `git branch -c` or - * `-m`. A declaration with no stamp was written before stamping existed and still counts. */ + * `-m`. A key listed twice mixes the declaration copied by `-C` or `-M` with the branch's own: + * neither is whole. A declaration with no stamp was written before stamping existed and still + * counts. */ function declaredByTheCommand(held, branch) { - return held.declared_at !== null && (held.declared_for ?? branch) === branch; + return !held.listed_twice && held.declared_at !== null && (held.declared_for ?? branch) === branch; } /** The branch's declaration ({task, ticket, declared_at}) when the command made it on this diff --git a/plugins/aidd-telemetry/skills/01-usage/actions/answer.md b/plugins/aidd-telemetry/skills/01-usage/actions/answer.md index 5c5e03b96..9fd4d4334 100644 --- a/plugins/aidd-telemetry/skills/01-usage/actions/answer.md +++ b/plugins/aidd-telemetry/skills/01-usage/actions/answer.md @@ -27,7 +27,7 @@ The figures the report holds for the question, each counter apart, followed by e - `coverage.records` of `0`: that no call is recorded for the period, and why that may be, from the reference. - `totals.records` of `0` with `coverage.records` above `0`: that a filter matched no call of the period, naming each as `filters` prints it, from the reference. 5. **Bound the past.** When the period starts before `coverage.oldest_transcript_at`, say that Claude Code may have removed older session files, so earlier figures can be incomplete. -6. **Offer the fix.** For work with no task, say it can be attributed afterwards by declaring the task from that branch with `aidd telemetry task [--ticket ]`. A branch renamed or copied since its declaration is undeclared under its new name, and is declared the same way. Declare nothing yourself. +6. **Offer the fix.** For work with no task, say it can be attributed afterwards by declaring the task from that branch with `aidd telemetry task [--ticket ]`. A branch renamed or copied since it was declared is undeclared under its new name (unless declared by `aidd` 5.5.0 or earlier), and is declared the same way. Declare nothing yourself. ## Test diff --git a/scripts/__tests__/aidd-telemetry-hooks.test.js b/scripts/__tests__/aidd-telemetry-hooks.test.js index 7f85e6155..36723b17f 100644 --- a/scripts/__tests__/aidd-telemetry-hooks.test.js +++ b/scripts/__tests__/aidd-telemetry-hooks.test.js @@ -284,11 +284,14 @@ test("the branch role follows the fixture", () => { } }); +/** A case's keys: an object, or pairs in order when a key is listed twice. */ +const entriesOf = (keys) => (Array.isArray(keys) ? keys : Object.entries(keys)); + test("a branch declaration read back from real git follows the fixture", () => { for (const [name, c] of Object.entries(cases.gitConfig)) { withBox({ aidd: false, branch: c.branch }, (box) => { - for (const [key, value] of Object.entries(c.keys)) { - git(box.repo, "config", "--local", `branch.${c.branch}.${key}`, value); + for (const [key, value] of entriesOf(c.keys)) { + git(box.repo, "config", "--local", "--add", `branch.${c.branch}.${key}`, value); } const found = lookup({ cwd: box.repo, sessionId: null, at: new Date(), dir: box.tel, env: process.env }); const want = expected.gitConfig[name] === "declared" ? "bound" : "unbound"; @@ -309,10 +312,10 @@ test("binding follows the fixture, through real git and the shared files", () => if (c.originHead) git(box.repo, "symbolic-ref", "refs/remotes/origin/HEAD", c.originHead); if (c.gitConfig) { const set = cases.gitConfig[c.gitConfig]; - for (const [key, value] of Object.entries(set.keys)) { + for (const [key, value] of entriesOf(set.keys)) { // the declaration belongs to the branch HEAD is on const branch = head.replace(/^refs\/heads\//u, ""); - git(box.repo, "config", "--local", `branch.${c.head === null ? set.branch : branch}.${key}`, value); + git(box.repo, "config", "--local", "--add", `branch.${c.head === null ? set.branch : branch}.${key}`, value); } } fs.mkdirSync(path.join(box.tel, "bindings"), { recursive: true }); @@ -357,6 +360,7 @@ test("parseBranchConfig cuts the known suffix off a dotted branch name", () => { ticket: null, declared_for: null, declared_at: "t", + listed_twice: false, }); }); @@ -711,6 +715,17 @@ test("a branch the command did not declare is asked: a task set by hand, a decla }); }); +test("the gate asks on a branch copied onto with -C, whose keys are listed twice", () => { + withBox({}, (box) => { + git(box.repo, "config", "--local", "branch.feat/x.aiddTask", "theirs"); + git(box.repo, "config", "--local", "branch.feat/x.aiddDeclaredFor", "feat/original"); + git(box.repo, "config", "--local", "branch.feat/x.aiddDeclaredAt", "2026-10-09T09:00:00.000Z"); + git(box.repo, "config", "--local", "--add", "branch.feat/x.aiddDeclaredFor", "feat/x"); + git(box.repo, "config", "--local", "--add", "branch.feat/x.aiddDeclaredAt", "2026-10-09T10:00:00.000Z"); + assert.equal(gate(box).json.decision, "block"); + }); +}); + test("the gate asks on every prompt until the branch is declared, then never again", () => { withBox({}, (box) => { assert.equal(gate(box).json.decision, "block"); diff --git a/scripts/__tests__/fixtures/telemetry-bindings/cases.json b/scripts/__tests__/fixtures/telemetry-bindings/cases.json index 544e1335d..c4269271d 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/cases.json +++ b/scripts/__tests__/fixtures/telemetry-bindings/cases.json @@ -69,6 +69,26 @@ "aiddTask": "half-written", "aiddDeclaredFor": "feat/x" } + }, + "copied-onto-a-branch-declared-none": { + "branch": "feat/x", + "keys": [ + ["aiddTask", "copied"], + ["aiddTicket", "PROJ-32"], + ["aiddDeclaredFor", "feat/original"], + ["aiddDeclaredAt", "2026-10-09T09:00:00.000Z"], + ["aiddDeclaredFor", "feat/x"], + ["aiddDeclaredAt", "2026-10-09T10:00:00.000Z"] + ] + }, + "task-listed-twice": { + "branch": "feat/x", + "keys": [ + ["aiddTask", "copied"], + ["aiddTask", "own"], + ["aiddDeclaredFor", "feat/x"], + ["aiddDeclaredAt", "2026-10-09T10:00:00.000Z"] + ] } }, "binding": { @@ -247,6 +267,13 @@ "head": "refs/heads/feat/x", "originHead": null }, + "branch-copied-onto-a-declared-branch-is-unbound": { + "session_id": null, + "at": "2026-10-09T15:00:00.000Z", + "gitConfig": "copied-onto-a-branch-declared-none", + "head": "refs/heads/feat/x", + "originHead": null + }, "branch-undeclared-is-unbound": { "session_id": null, "at": "2026-10-09T15:00:00.000Z", diff --git a/scripts/__tests__/fixtures/telemetry-bindings/expected.json b/scripts/__tests__/fixtures/telemetry-bindings/expected.json index 8b48ebe02..4a11936b4 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/expected.json +++ b/scripts/__tests__/fixtures/telemetry-bindings/expected.json @@ -9,7 +9,9 @@ "dotted-branch-name": "declared", "stamped-for-this-branch": "declared", "stamped-for-another-branch": "undeclared", - "stamp-without-declared-at": "undeclared" + "stamp-without-declared-at": "undeclared", + "copied-onto-a-branch-declared-none": "undeclared", + "task-listed-twice": "undeclared" }, "binding": { "session-declared-once": { @@ -164,6 +166,9 @@ "branch-stamp-without-declared-at-is-unbound": { "state": "unbound" }, + "branch-copied-onto-a-declared-branch-is-unbound": { + "state": "unbound" + }, "branch-unstamped-legacy-declaration-is-bound": { "state": "bound", "source": "branch", From 88755b107ef5fdab320f983b47fe7131ce0b37f6 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 22:09:31 +0200 Subject: [PATCH 08/13] docs(telemetry): record review round 2 and the gates it reran Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../evidence.md | 7 +++++++ .../review.md | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md index 3b83625ee..0a43b3467 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md @@ -128,3 +128,10 @@ Each mutation applied alone to the source, the named suite run, the file restore - Probe: after `git branch -C x y` the config holds `[branch "y"]` twice, the copied section first; both readers kept the last value of a key. Round 2 showed this mixes the two declarations when the branch's own left a key unset (see below). - Guards: plain set (S1) and plain unset (S2) each turn `declares again over a branch copied onto with -C…` red; the stamp mutation (M2) turns `asks again after a rename, and one declaration on the new name covers it` red (now checked on a call made on the renamed branch, `feat/c`). - Gates on 703b646e: `cd cli && pnpm test` `Tests 6669 passed | 1 skipped (6670)`; `node --test 'scripts/__tests__/*.test.js'` `ℹ tests 459`, `ℹ fail 0`; bundle `693.5 KB / budget: 695 KB`; mutation `score 96.5, floor 96`, changed lines: `branch-binding.ts` 17, `binding-resolution.ts` 4, `attribution.ts` 15, store adapter 11, source and forget adapters 1 each, all killed; smoke `PASS: 132 FAIL: 0 SKIP: 1`; tokens end to end and `--attended`: same lines as above. + +## Review round 2 fixes + +- Red first: CLI `× reads nothing from a branch copied onto with -C, which lists a key twice`, `× reads nothing from a branch whose task alone is listed twice`, `× declares again over a branch copied onto with -C…`, `× reads copied-onto-a-branch-declared-none as the fixture says`, `× binds branch-copied-onto-a-declared-branch-is-unbound` (`Tests 5 failed | 1220 passed (1225)`); hook `✖ a branch declaration read back from real git follows the fixture`, `✖ binding follows the fixture…`, `✖ the gate asks on a branch copied onto with -C, whose keys are listed twice` (`ℹ fail 3`); e2e `× does not mix a declaration copied with -C into the one the branch already had`, `AssertionError: expected 3 to be 2`. +- Green: `Tests 1225 passed`, hook `ℹ fail 0`, e2e `Tests 5 passed (5)`. +- Guards: T1 (CLI ignores a key listed twice) turns 6 unit tests and the e2e red; T2 (`||=` to `=`) turns `reads nothing from a branch whose task alone is listed twice` red; HT1 (hook ignores it) turns 3 hook tests red; HT2 (`||=` to `=`) survived until the fixture case `task-listed-twice` was added, then `✖ a branch declaration read back from real git follows the fixture`. +- Gates on 0c5eab26: `cd cli && pnpm test` `Tests 6676 passed | 1 skipped (6677)`; `node --test 'scripts/__tests__/*.test.js'` `ℹ tests 460`, `ℹ fail 0`; bundle `693.6 KB / budget: 695 KB`; mutation `score 96.4, floor 96`, changed lines: `branch-binding.ts` 24, `attribution.ts` 15, store adapter 11, `binding-resolution.ts` 4, source and forget adapters 1 each, all killed; smoke `PASS: 132 FAIL: 0 SKIP: 1`; tokens end to end and `--attended`: unchanged lines. diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md index e5ddcd38b..994f8602f 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md @@ -20,3 +20,15 @@ | 8 | suggestion | fixture JSON reformatted unrelated entries | fixture re-applied as text insertions; diff now only the new cases | Acceptance after round 1: 1, 2, 4, 5, 6 fulfilled; 3 and 8 partial (findings 5, 1, 3); 7 not re-run by the checker. + +## Round 2 + +- Diff: `origin/next...fd7a31d6` +- Verdict: request changes, 7/10. Round 1 findings 1-5, 7, 8 confirmed resolved; 6 partial. + +| # | Class | Finding | Resolution (0c5eab26) | +| --- | --- | --- | --- | +| D1 | deal breaker | after `git branch -C` onto a declared branch, both readers kept the last value per key, mixing the copied task or ticket into the branch's own declaration | any declaration key listed twice makes the branch undeclared, in both readers; red tests in each, fixture case listed in order, e2e; usage contract and contract decision 7 corrected | +| S1 | suggestion | README and skill said a copied branch is always asked again; legacy copies are inherited | both name the cli-v5.5.0 exception | +| S2 | suggestion | forget count test did not include `aiddDeclaredFor` | count test now has four keys | +| S3 | suggestion | spec.md:34 "at most once" unexplained | reading note added under it, pointing to the usage contract | From 6c52b68cc5b471f9e547e8cac65b05f76223d2da Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 16 Oct 2026 22:22:49 +0200 Subject: [PATCH 09/13] fix(telemetry): a branch starts when it takes its name, so a copy's redeclaration counts git branch -c, -m, -C and -M bring the source's reflog along, so the oldest entry is the source's creation. On a name used before, that made the earlier branch's declaration look newer than one made after the copy, and the report kept it while status and the gate showed the new one. The start is now the newest entry where the branch took its name, else its oldest entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 5 +- .../contract.md | 3 +- .../evidence.md | 19 +++++++ .../review.md | 11 ++++ .../telemetry/domain/branch-binding.ts | 37 +++++++++---- .../git-branch-binding-source-adapter.ts | 6 +-- .../domain/branch-binding.unit.test.ts | 42 +++++++++++---- ...binding-source-adapter.integration.test.ts | 10 ++++ .../telemetry-report-declaration.e2e.test.ts | 54 ++++++++++++++++++- 9 files changed, 159 insertions(+), 28 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 688f0db6a..4623f7383 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -149,7 +149,7 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | `ledger/.lock` | CLI | CLI | `{pid, created_at}`; dead process or older than ten minutes: cleared | | `bindings/.lock` | CLI | CLI | same format. Declarations and snapshots run under it; `task` never takes the ledger lock, which an ingest holds while reading transcripts | | `bindings/sessions.jsonl` | `task` | hooks, `report`, `task` | `{session_id, task, ticket, none, declared_at, by}`, `by` = `command` \| `hook-intercept` | -| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with branch creation time from its reflog | +| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with when the branch took its name, 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`, `on` | `report`, `status`, `on` | `{"person_id": ""}`, owner-only | @@ -193,7 +193,7 @@ A branch is declared only when the command declared it on that branch. Both read | `aiddTask` or `aiddTicket` without `aiddDeclaredAt` | no | set by hand, or a declaration cut short: `task` writes `aiddDeclaredAt` last | | any declaration key listed twice | no | left by `git branch -C` or `-M` onto a branch that had keys: the copied declaration and the branch's own are mixed | -- **Rename** (`git branch -m A B`): B is undeclared, so the gate asks on B. One declaration on B covers B from its creation, since the reflog moves with the branch; calls made while it was A keep A's task. git moves the keys the same way for a copy and a rename, and telling them apart would mean reading reflog entries that expire: one question after a rename is the cost. +- **Rename** (`git branch -m A B`): B is undeclared, so the gate asks on B. One declaration on B covers B from when it took the name: the newest reflog entry renaming or copying a branch to B, else B's oldest entry. Calls made while it was A keep A's task, and on a name used before, the earlier B keeps its own. git moves the keys the same way for a copy and a rename, and telling them apart would mean reading reflog entries that expire: one question after a rename is the cost. - **Copy** (`git branch -c A B`): B is undeclared until declared on B. - **Copy or rename onto an existing branch** (`-C`, `-M`): git keeps both sets of keys, so some are listed twice and the two declarations mix. A branch with any declaration key listed twice is undeclared; declaring again replaces every value. @@ -324,6 +324,7 @@ None observable by the CLI. | `AIDD_TELEMETRY=0` | stops hooks too; a key turned off meanwhile is observed at the next run without it | - | | Every declaration key written by hand, `aiddDeclaredFor` and `aiddDeclaredAt` included | the branch reads as declared: no reader can tell who wrote a git config key | - | | Copy of an unstamped (cli-v5.5.0) declaration | the copy inherits it | declare on the copy, or on the original to stamp it before copying | +| Reflog entry of the rename or copy expired before B's first declaration | B's start is its oldest remaining entry; on a name used before, an older start can let the earlier B's declaration speak for later calls | declare soon after a rename or copy | | Branch snapshots recorded before stamping (a copied branch, a hand-set task with a `declared_at`) | keep attributing: attribution reads snapshots, `branches.jsonl` is append-only | - | ## Never leaves the machine diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md index cc1313d48..fb57e6e66 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/contract.md @@ -21,10 +21,11 @@ The product rule ([usage-contract](../../../product/usage-contract.md#attributio | 1 | The command also writes `branch..aiddDeclaredFor = `, before `aiddDeclaredAt`, which stays last. | Ties the declaration to the branch it was made on. A copy or a rename moves the keys under another name; the value keeps the old one. | | 2 | A branch is declared only when `aiddDeclaredAt` is set and `aiddDeclaredFor`, when present, equals the branch read. Otherwise it is undeclared: no task, no ticket, no `none`. | `aiddDeclaredAt` is the last key the command writes, so it alone says the command finished. The stamp rejects a copied declaration. | | 3 | A declaration with no `aiddDeclaredFor` (written by cli-v5.5.0 and earlier) is read as before, if `aiddDeclaredAt` is set. Declaring again stamps it. | Beta, but data written by a released CLI keeps its meaning. Rejecting it would make `status` and the gate ask again on every branch already declared, for a hole only a copy opens. | -| 4 | `git branch -m A B` leaves B undeclared: the gate asks on B, and one declaration on B covers it again from its creation. Calls made on A keep A's task. | git moves the config keys the same way for a copy and a rename; telling them apart means reading the reflog, whose entries expire. The cost is one question after a rename, and nothing is lost: the reflog moves with the branch, so B's first declaration still covers B from A's creation. This replaces "follows a rename" in the usage contract. | +| 4 | `git branch -m A B` leaves B undeclared: the gate asks on B, and one declaration on B covers it from when it took the name B. Calls made on A keep A's task. | git moves the config keys the same way for a copy and a rename; telling them apart means reading the reflog, whose entries expire. The cost is one question after a rename, and nothing is lost: calls on B all come after B took its name. This replaces "follows a rename" in the usage contract. | | 5 | A branch snapshot with `declared_at: null` attributes nothing. | Such a snapshot comes only from keys set by hand or a declaration cut short: every release wrote `aiddDeclaredAt`. | | 6 | M8: the doc is wrong, not the hook. The gate blocks every prompt on an undeclared working branch until it is declared (or declared `--none`). A declared branch is never asked again. | The spec that introduced the gate (`2026_10_09_deterministic-tokens/spec.md`) requires asking "before any token of that work is spent", and its target (`context/target.md`, "When to ask") re-checks every prompt. "Once" meant "remembered once answered". Letting later prompts through would spend tokens unattributed and need per-branch state. | | 7 | A branch with any declaration key listed twice is undeclared, and the command replaces every value of a key (`--replace-all`, `--unset-all`). Added after review rounds 1 and 2. | `git branch -C` or `-M` onto an existing branch keeps both sets of keys: reading the last value of each mixed the copied declaration into the branch's own (a ticket or task the own one never set), and a plain `git config` set refuses a key with two values, so declaring again failed. | +| 8 | A branch's start is the newest reflog entry where it took its name by a copy or a rename, else its oldest entry. Added after review round 3. | A copy or a rename brings the source's reflog, so the oldest entry is the source's creation. On a name used before, that made the older branch's declaration look newer than the one made after the copy, and the report kept it while `status` and the gate showed the new one. | ## Limits (stated, not fixed) diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md index 0a43b3467..ede79de19 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/evidence.md @@ -135,3 +135,22 @@ Each mutation applied alone to the source, the named suite run, the file restore - Green: `Tests 1225 passed`, hook `ℹ fail 0`, e2e `Tests 5 passed (5)`. - Guards: T1 (CLI ignores a key listed twice) turns 6 unit tests and the e2e red; T2 (`||=` to `=`) turns `reads nothing from a branch whose task alone is listed twice` red; HT1 (hook ignores it) turns 3 hook tests red; HT2 (`||=` to `=`) survived until the fixture case `task-listed-twice` was added, then `✖ a branch declaration read back from real git follows the fixture`. - Gates on 0c5eab26: `cd cli && pnpm test` `Tests 6676 passed | 1 skipped (6677)`; `node --test 'scripts/__tests__/*.test.js'` `ℹ tests 460`, `ℹ fail 0`; bundle `693.6 KB / budget: 695 KB`; mutation `score 96.4, floor 96`, changed lines: `branch-binding.ts` 24, `attribution.ts` 15, store adapter 11, `binding-resolution.ts` 4, source and forget adapters 1 each, all killed; smoke `PASS: 132 FAIL: 0 SKIP: 1`; tokens end to end and `--attended`: unchanged lines. + +Count note (round 3, S2): after the round 2 changes the CLI telemetry suites hold `Tests 1226 passed (1226)` and the hook suite `ℹ tests 66`, `ℹ fail 0`; the 1225 above was before the `task-listed-twice` fixture case. + +## Review round 3 fixes + +- Probe, dated git in a scratch repository: every way a branch takes a name writes one reflog subject, and the source's reflog comes along. + +```txt +-C: b@{2026-10-05T00:00:00Z} | Branch: copied refs/heads/a to refs/heads/b + b@{2026-10-01T00:00:00Z} | branch: Created from main <- a's creation +-M: d@{2026-10-05T00:00:00Z} | Branch: renamed refs/heads/c to refs/heads/d +-D;-m: f@{2026-10-05T00:00:00Z} | Branch: renamed refs/heads/e to refs/heads/f +-c;-m: i@{2026-10-06T00:00:00Z} | Branch: renamed refs/heads/h to refs/heads/i + i@{2026-10-05T00:00:00Z} | Branch: copied refs/heads/g to refs/heads/h +``` + +- Red first, e2e: `× lets a declaration on a branch copied onto with -C reach the report, the source older`, `× lets a declaration on a name renamed onto, after the old branch was deleted, reach the report`, both `AssertionError: expected undefined to be 1` (the new declaration's row is missing: its call went to `old-b`). Integration: `× is when the branch took its name, for a copy or a rename onto a name used before`. Unit tests red on the missing `branchCreatedAt` export. +- Green: `vitest tests/contexts/telemetry` `Tests 1229 passed (1229)`; `vitest --project=e2e tests/e2e/telemetry` `Tests 79 passed (79)`. The rename e2e now dates its rename (10-08) before the call it adds, as it happens in real use. +- Guards: R1 (always the oldest entry) turns both new e2e tests, the integration test and `takes the newest entry where the branch took its name…` red; R2 (copies not matched) turns the integration and that unit test red; R3 (any target name) turns `ignores an entry that moved another name, or that names this one only as a prefix` red. diff --git a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md index 994f8602f..f29b228a2 100644 --- a/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_16_declaration-comes-only-from-the-command/review.md @@ -32,3 +32,14 @@ Acceptance after round 1: 1, 2, 4, 5, 6 fulfilled; 3 and 8 partial (findings 5, | S1 | suggestion | README and skill said a copied branch is always asked again; legacy copies are inherited | both name the cli-v5.5.0 exception | | S2 | suggestion | forget count test did not include `aiddDeclaredFor` | count test now has four keys | | S3 | suggestion | spec.md:34 "at most once" unexplained | reading note added under it, pointing to the usage contract | + +## Round 3 + +- Diff: `origin/next...88755b10` +- Verdict: request changes, 6.5/10. Rounds 1 and 2 confirmed resolved; readers agree; M8 decision confirmed. + +| # | Class | Finding | Resolution | +| --- | --- | --- | --- | +| D1 | deal breaker | after `-C`/`-M` onto a declared branch, or a rename onto a reused name, declaring again did not reach the report: the copied reflog made the old generation look younger (`attribution.ts` `generationAt`) | the branch's start is now read as the newest reflog entry where it took its name (`branchCreatedAt`), else its oldest; two e2e tests red first; contract decision 8, usage contract rename bullet and a reflog-expiry limit | +| S1 | suggestion | forget over-counts duplicated keys after `-C` | kept: forget counts the config entries it removes, so the number is what it deletes | +| S2 | suggestion | evidence counts stale (1225, hook count missing) | re-recorded in `evidence.md` | diff --git a/cli/src/contexts/telemetry/domain/branch-binding.ts b/cli/src/contexts/telemetry/domain/branch-binding.ts index e962170ba..62c0e5f5d 100644 --- a/cli/src/contexts/telemetry/domain/branch-binding.ts +++ b/cli/src/contexts/telemetry/domain/branch-binding.ts @@ -17,7 +17,8 @@ export interface BranchConfigBinding { * the work it carried. */ export interface BranchSnapshot extends BranchConfigBinding { readonly repository_id: string; - /** The oldest reflog entry when first read: the work before it was not on this branch. */ + /** When the branch took its name, from its reflog when first read (see `branchCreatedAt`): + * the work before it was not on this branch. */ readonly branch_created_at: string | null; readonly snapshot_at: string; } @@ -102,18 +103,34 @@ export function parseBranchConfig(output: string): BranchConfigBinding[] { })); } -/** `git reflog show --date=iso-strict --format=%gd` lists newest first, each as - * `refs/heads/@{