Repository navigation
feat(aidd-telemetry): a task declares its backlog once, and every session on it is found #975
Description
Problem
The report answers "what did this task cost". It cannot answer "what did issue #661 cost" with anything a reader should trust, because the only upward link — backlog-link.json — is written by the model itself, from prose, as a step inside three skills (plugins/aidd-pm/skills/04-spec/actions/01-build.md:22-34, plugins/aidd-dev/skills/01-plan/actions/04-plan.md:16-26, plugins/aidd-orchestrator/skills/01-sdlc/references/01-frame.md:7). Nothing obliges a model to follow a written instruction, and nothing measures whether it did.
The obvious fixes all fail for reasons that are measurable in this repository today, not hypothetical. This issue records what was measured, what was therefore dropped, and the one shape that survives.
What was measured
| Fact | Command | Result |
|---|---|---|
| The session trailer is not parseable as a trailer | git log next -60 --format='%(trailers:key=AIDD-Session-Id,valueonly)' |
0 parsed, while --grep finds the literal string in 44 of the same 60 commits, and Co-authored-by parses from 45 |
| Why | git log -1 --format=%B <commit> |
the squash appends --- and a trailing Co-authored-by paragraph after ours; git parses only the last paragraph |
| A branch rarely maps to one task on the expensive changes | distinct task folders touched per squashed PR, last 300 commits of next |
45 PRs touch 1 folder, 4 touch 2, one touches 5, two touch 8, one touches 20 |
| The hook does not know the branch today | grep -rn "abbrev-ref|symbolic-ref|show-current|rev-parse HEAD" plugins/aidd-telemetry |
no match |
| The task-path scrape records non-paths as task identities | the run journals in this repository | aidd_docs/tasks/2026_08/*/ is journalled as task_declared; taskIdentityFromWrittenPath turns it into the identity 2026_08/*. Also plan.md. and spec.md; (shell punctuation). 119 task_declared lines for 52 distinct paths in two sessions |
git config branch.<name>.* behaves the way a task link must |
scratch repository | follows git branch -m (value intact under the new name, gone from the old), and a deleted-then-recreated branch name reads empty |
What this rules out
A branch is not a key. It is a reading, not an identity: cli/src/domain/models/task-attribution.ts already refuses an unbounded task interval — "no tool exposes when a flow leaves a ticket, so a boundless interval would attribute everything a long-running session goes on to do to the first ticket it ever named". A branch is that same unbounded interval with the branch's lifetime as its span. It is right about 85% of the time and silently wrong on the largest changes, which is the worst distribution: an abstention is cheaper than a confident wrong number. Gerrit reaches the same conclusion from the other side — a Change-Id is explicitly "not necessarily unique within a Gerrit instance; it can be reused among different repositories or branches" — and OpenTelemetry's vcs.ref.head.type is documented as "where you are right now; the current reference at a given time". Neither treats a ref as an identity.
A second commit trailer buys nothing. The trailer we already write is invisible to git interpret-trailers, %(trailers) and gh after this repository's own squash merges (0/60 above). Renaming it to AIDD-Task inherits that exactly. Resolving a task from a committed trailer would also have to happen inside the prepare-commit-msg delegate, which is POSIX sh by deliberate decision — "no Node, no dependency on this CLI still being installed" (cli/src/domain/formats/commit-session-trailer.ts, doc on sessionTrailerDelegateScript) — so it would mean scanning and parsing JSON in sh, or a hard Node dependency in a script whose point is not having one.
The hook must not write a task card. plugins/aidd-telemetry/hooks/lib/repo.cjs:288-313 already refuses to write into a checkout it does not own, because that "would dirty a checkout on a different branch, possibly with uncommitted work of its own". Writing an unasked file into the user's own tree is the same intrusion. It also feeds itself: lib/file-writes.cjs:117-166 journals every file under a task folder whose mtime is newer than the run file, so a card the hook writes is journalled on the next turn as a file written by the session — attribution evidence for a file nobody authored.
The attribution enum must not be re-sorted. declared is today the strongest value of by_task.attribution (cli/src/domain/models/task-attribution.ts:21, label at cli/src/application/display/cost-report-display.ts:36-39). A four-rung ladder that splits today's declared population into a new strongest rung and a new weakest rung changes what a shipped string means, in the one field a destination aggregates on.
Proposed shape
Two questions that the previous design conflated, each on the support that fits it:
- Which backlog item does this task deliver? Shared, durable, belongs in the repository:
backlog-link.jsonstays, with its name, its three states (declared/none/unreadable) and its existing provenance pairwritten_by/written_at. What changes is who writes it: a command, from its arguments, never prose. - Which task is this working tree on right now? Local, mutable, per-worktree:
git config branch.<name>.aiddTask. It follows a rename, dies with the branch, does not haunt a reused name, and reads in one line from theshdelegate.
Scope
aidd task start <slug> [--backlog <ref>]…— creates or reuses the task folder, writesbacklog-link.jsonfrom its arguments, and setsbranch.<current>.aiddTaskto the task identity. Refuses, naming the reason, on a detached HEAD (git rev-parse --abbrev-ref HEADprints the literalHEADduring rebase, cherry-pick, bisect and in a detached worktree) and on an integration branch.aidd task attach <task>— sets the same key for work already under way, and for a second machine or a second person on the same branch.- The hook reads
git config --get branch.<branch>.aiddTaskatsession_startand when the branch changes, and journals it as a timestamped observation. It writes no card, derives no identity: "a written path is recorded, never a derived task_id: that derivation belongs to the reader" (plugins/aidd-telemetry/hooks/lib/file-writes.cjs:1-3). Cost is one more option on thegit rev-parsecall the hook already makes per event (lib/repo.cjs:95-121). - The reader builds intervals from that observation the way it already does for
task_declared, bounded by the session's own witnessed span. If a new attribution value is needed, it is added; no existing value changes meaning or rank. aidd task show [<task>]— sessions, steps, files, commits and cost for one task, derived at read time, nothing frozen.checkstates what it cannot resolve rather than guessing: no key on this branch, a key naming a folder that does not exist, an unreadablebacklog-link.json, a task folder outside the rootaidd_docs/tasks/tree.
Acceptance criteria
- The same arguments produce the same bytes in
backlog-link.json, modulo the timestamp. - A session started on a branch carrying the key is attributed to that task from its first request, with no model instruction involved, on every tool whose hook runs.
- A session on a branch with no key is attributed by the routes that exist today, and never by a guess at the branch.
- Renaming the branch keeps the attribution; deleting and recreating the branch name yields no attribution rather than the old one.
- Running
reporttwice over the same past window returns the same per-task totals, before and after a new task is started — a link created today never rewrites yesterday. checknames every unresolvable case above, with the path or branch it is talking about.- An unreadable
backlog-link.jsondisables the link visibly; it never falls through to a weaker route that would produce a plausible number from a file the user broke.
Prior art in this repository
cli/src/domain/models/task-backlog-link.ts— why this file carries one fact and not a fuller card, and the three-state result.cli/src/domain/models/task-attribution.ts— why a task interval is never left open-ended, with the measured cost of getting it wrong.cli/src/domain/models/task-identity.ts:1-6— the journal stores observations, the reader derives; "a conclusion frozen at write time cannot be revised".cli/src/domain/formats/commit-session-trailer.ts— the delegate's constraints, and its refusal of a second identity for one thing.plugins/aidd-telemetry/hooks/lib/repo.cjs— the git facts the hook already collects, and what it refuses to write.
Out of scope
- Any hosted destination: sending, identity through a remote, prices, team and epic aggregation.
- Changing
by_task.attribution's existing values or their order. - A
metadata.jsoncarryingid,title,base,created_atorparent: each is derivable from the folder path, from git, or from the forge, andvcs.mdalready makes the branch prefix the authority for the PR base. - Rewriting history to repair unparseable trailers in merged commits.
Defects found while measuring this
Each stands on its own and can be split out when picked up.
- The session trailer is not parseable after a squash merge. 0 of 60 recent
nextcommits exposeAIDD-Session-Idthrough%(trailers), though 44 contain the string: our block stops being the last paragraph once the squash appends its own. Nothing reads it today, so nothing broke loudly. - The task-path scrape produces phantom task identities. A glob (
aidd_docs/tasks/2026_08/*/) and punctuation-suffixed paths (plan.md.,spec.md;) are journalled astask_declaredand become identities such as2026_08/*, at the strongest attribution strength. Validate a scraped path against the filesystem before it becomes an identity. - mtime-based observed writes are polluted by plain git operations.
checkout,pull,rebaseandstash applyrewrite the mtime of every differing task document; the next turn journals them as written by the current session (lib/file-writes.cjs:60-104records on mtime alone). Cross-check against the working tree's actual changes before journalling an observed write. - Task folders outside the root tree are invisible to every route.
cli/aidd_docs/tasks/andkanban/aidd_docs/tasks/exist here, but both anchors require^aidd_docs/tasks/relative to the repository root (lib/file-writes.cjs:22,cli/src/domain/models/task-identity.ts:14). That work reports as "no declaration" — a claim about the work, when it is a limit of the pattern. - On a fork, the project's remote resolves to the wrong
owner/repo.lib/repo.cjs:172readsoriginonly, so a backlog shorthand expanded from the remote names a repository that does not hold the issue.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fieldsDone
Activity
VincentMenard76 commentedon Oct 9, 2026
One more defect of the same family as "task folders outside the root tree", surfaced by #932.
declaration: "none". Since fix(aidd-telemetry): a session run in a git worktree is missing from every report outside that worktree, and lost when the worktree is removed #932, sessions from every worktree of a clone are counted from any checkout. A task created on another worktree's unmerged branch therefore reaches the backlog axis, which looks for<task>/backlog-link.jsonin the current checkout only (cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts,repositoryRootAbove(projectRoot)thenENOENT→{ kind: "none" }). The contract definesnoneas "its folder declares no backlog item" (aidd_docs/product/cost-report-contract.md,by_backlog); a folder that is not there at all is a different fact — an unknown printed as a nothing. Proposed: when the task folder itself is absent, answer a distinctdeclarationvalue (e.g."absent"), sononekeeps exactly its documented meaning and no existing value changes rank. It resolves on its own once the branch is merged and the checkout is up to date; a correction made by hand inbacklog-link.jsonkeeps applying, since nothing is frozen in the journal. Adding a value a consumer must tell apart likely bumpscost_report_version.blafourcade commentedon Oct 15, 2026
Superseded by #993: the task and ticket now come only from
aidd telemetry task <name> [--ticket <ref>]; no backlog file is read.