/.opencode/plugin/*.js` is found with no config entry at all -
+ confirmed live: every probe's `--print-logs` output named the exact path.
+- **The loader's own validation, decompiled from the binary.** For each discovered module,
+ `yL(mod, spec, "server", "detect")` reads `mod.default`; if that is a plain object
+ carrying `id`/`server`/`tui`, its `.server` is called directly as the plugin. If not - a
+ bare function is not a "plain object" by this check - the loader falls back to `qq0(mod)`,
+ which walks `Object.values(mod)`, dedupes, and calls every function-typed export it finds.
+ Both paths were reachable by the shapes tried; neither one ever ran.
+- **The real, working reference on this machine.** `~/Library/Application Support/
+ orca/opencode-hooks/shared/plugins/orca-opencode-status.js`, installed by a different tool
+ (Orca) already running on this machine as a production dependency, uses genuine ESM:
+ `export const OrcaOpenCodeStatusPlugin = async (_ctx) => {...}` - a named export, no
+ `default`, no `{server}` wrapper. That file's own `service=plugin ... loading plugin` line
+ appears in every capture alongside the probe's.
+
+### Finding 1: OpenCode's loader requires a genuine ESM export
+
+Free, via `opencode models` (loads plugins, creates no session): a file identical in every
+way to the failing CommonJS attempts except for its export statement -
+
+```js
+export const ProbePlugin = async (input) => {
+ fs.appendFileSync(LOG, JSON.stringify({ at: "esm-server-called", directory: input.directory }));
+ return { event: async ({ event }) => { /* ... */ } };
+};
+```
+
+- ran on the very first attempt. The log file existed after the command returned, with both
+the module's own top-level log line and the `server()` call's, `directory` correctly naming
+the project root. No CommonJS variant - bare function, `{server: fn}`, or a version carrying
+every alias (`module.exports`, `.default`, `.server`, a named property) at once - ever
+produced this, across three billed sessions plus repeated free attempts. The conclusion
+this phase draws is precise: **the plugin module itself must be ESM** (`import`/`export`),
+regardless of file extension (`.js` works; the loader sniffs content, not the name, exactly
+as OpenCode's own bundled reference plugin does).
+
+### Finding 2: the id is seen, with zero AI spend, once the export is fixed
+
+Still free - `opencode serve --port ` (a headless server, no session created on its own)
+plus a direct `curl -X POST http://127.0.0.1:
/session`, which creates a session (a
+database row and a `session.created` event) without ever sending a message, so no model is
+ever called and nothing is billed:
+
+```json
+{"at":"esm-event","type":"session.created",
+ "properties":{"sessionID":"ses_fd7d6e979ffed8boswipOz9USp",
+ "info":{"id":"ses_fd7d6e979ffed8boswipOz9USp", "directory":"/private/tmp/.../opencode-probe", ...}}}
+```
+
+`opencode export ses_fd7d6e979ffed8boswipOz9USp --sanitize` (the exact command
+`opencodeRead` in `readers.js` already shells out to) accepted that same id and returned the
+session's own record - the identical id the plugin's `event` hook saw is the one
+`mapOpencodeExportToSinkRecords`/`opencodeRead` already key their `vendor_id` on. The
+question phase 5 exists to answer - does anything running inside a session see that
+session's own identifier, and is it the same one the reader already uses - is settled,
+affirmatively, by a live capture.
+
+### Finding 3: the loader cannot see a local CommonJS file's exports either
+
+A second, independent limit, found while wiring the actual join: `await
+import("./lib/record.js")` from inside a loaded OpenCode plugin resolves to a namespace with
+**zero** own properties - no `default`, no named export - even for a one-line throwaway file
+(`module.exports = { foo: 42, bar: () => "hi" }`), while a **genuinely ESM** sibling file
+(`export const foo = 42;`) imports correctly, both by relative path and by an absolute
+`file://` URL. So this is not a resolution problem (the file is found, `import()` resolves
+without throwing) - it is specifically that OpenCode's loader does not perform CommonJS/ESM
+interop for a plugin's own further imports, the same gap Finding 1 already showed for the
+plugin's own top-level export. `hooks/lib/record.js` and `hooks/lib/repo.js` - the shared,
+zero-dependency journal primitives every other host's hook already runs through - are
+CommonJS, and stay CommonJS: they are `require()`d as a child process by `journal.js` under
+Claude Code, Codex, Copilot and Cursor's own `hooks.json`, and converting them to ESM to
+suit OpenCode alone would touch every one of those paths for no gain.
+
+### The design this settles on
+
+`hooks/opencode-plugin.js` does not import `lib/record.js` in-process at all. It spawns
+`journal.js` - the exact same child process every other host's hook already runs - over the
+same stdin-JSON contract, from `session.created` and `session.idle`, naming the payload
+`{tool: "opencode", session_id, cwd}` so `detectHost` (`lib/host.js`) recognises it without
+inventing a fifth vendor-payload shape to guess at (every other host's shape was reverse
+engineered from a capture nobody here controls; this one is authored by this plugin, so it
+gets to name itself unambiguously). One more free-tier bug caught this way, also live: the
+first version spawned `process.execPath` - which names the `opencode` binary itself, not a
+Node runtime, since OpenCode ships as its own standalone executable - and silently ran
+nothing; fixed by spawning `node` explicitly.
+
+End to end, free, via the same `opencode serve` + `POST /session` route: a real journal line
+appeared, matching the shape every other tool's `session_start` line already has -
+
+```json
+{"type":"session_start","at":"2026-08-22T06:36:55Z","schema_version":2,
+ "run_id":"01M0M2ZJJCGFWB1NW9VX20ZPN2","project_id":"example/opencode-probe",
+ "project_remote":"https://github.com/example/opencode-probe.git","tool":"opencode",
+ "vendor_id":"ses_fd7d035efffeEkq6HyYAWt9Z63","vendor_field":null}
+```
+
+Then the actual sweep - `node telemetry-report.js read`, with no `--session` named by hand,
+run against the project that now held two of these files - reported:
+
+```
+ 2 sessions read, 0 with records
+ ...
+ OpenCode: read, nothing found — read alone: no captured payload establishes that a hook or
+ plugin sees OpenCode's own session id, so these figures cannot yet be joined to a run
+ journal entry.
+```
+
+"2 sessions read" is the proof: the sweep discovered both OpenCode sessions from the journal
+alone, exactly as it already does for every other tool, with nobody naming a session id by
+hand. "0 with records" is expected and correct - no message was ever sent to either session
+(that would have spent real budget), so `opencode export` legitimately has no counted
+message to return; status `empty`, not `not-found`, meaning the export call itself
+succeeded and simply found nothing to count. The stale `reason` text printed above is the
+declaration this phase's own code change replaces (see below) - captured before that edit,
+kept here verbatim because it is what the sweep actually printed at that moment.
+
+The `turn-end` dispatch itself was checked too, free and directly: a synthetic
+`{tool:"opencode", session_id, cwd}` payload matching an already-written `session_start`,
+piped straight into `journal.js turn-end`, appended a `turn_end` line to that same run file -
+proving the plumbing `hooks/opencode-plugin.js` drives from `session.idle` end to end. What
+was not observed in this phase is OpenCode's own `session.idle` firing with a real id: it
+fires only after a message the agent has processed, and no message was sent, on budget
+grounds. It carries the identical `event` callback and an identically-shaped
+`properties.sessionID` field per `@opencode-ai/plugin`'s own shipped types
+(`EventSessionIdle`), delivered through the same mechanism `session.created` already proved
+works - so the residual gap is narrow: not whether the dispatch works, but whether OpenCode
+actually fires this one event the way its own types say it does. Named here as the one line
+item in this phase not backed by its own live capture.
+
+### What changed
+
+- **`plugins/aidd-telemetry/hooks/opencode-plugin.js`** (new): the plugin module itself,
+ ESM, as described above.
+- **`plugins/aidd-telemetry/hooks/lib/host.js`**: `DECLARED_HOSTS` gains `"opencode"`, and
+ `detectHost` gains one new branch (`payload.tool === "opencode"`), checked **last** -
+ after every vendor-shape check, not before. No captured fixture from any other host
+ carries a top-level `tool` key today (checked: `scripts/__tests__/fixtures/*.json`), but
+ the ordering costs nothing and means a future vendor payload that happened to add one
+ would still be claimed by its own shape first, never misattributed to OpenCode.
+- **`plugins/aidd-telemetry/hooks/lib/record.js`**: `SESSION_ID_READER_BY_HOST.opencode`
+ reads `payload.session_id` (the plugin's own payload already spells it that way);
+ `VENDOR_FIELD_BY_HOST.opencode` is `null` - the same fact Cursor's entry already states,
+ for the same reason: `opencode.ts`'s own `telemetryExport` is declared `"unmeasured"`
+ (that is #653's probe, not this one), and a guessed OTEL attribute name here would be
+ exactly the false figure this field exists to prevent.
+- **`plugins/aidd-telemetry/hooks/lib/repo.js`**: `CWD_READER_BY_HOST.opencode` reads
+ `payload.cwd` (same spelling as every host but Cursor).
+- **`plugins/aidd-telemetry/skills/01-cost/scripts/lib/readers.js`** and its byte-parity
+ copy at `plugins/aidd-telemetry/skills/02-check/scripts/lib/readers.js`:
+ `capability.journalAttributable` flips from `false` to `true`, backed by the live sweep
+ above; the stale `limitation` text (which described the pre-phase-5 state) is replaced by
+ a comment naming this probe. Both copies confirmed byte-identical after the edit.
+- **`scripts/__tests__/telemetry-cost-readers.test.js`**: the unreachable-tools assertion
+ changes from `["opencode"]` to `[]`.
+- **`scripts/__tests__/telemetry-check.test.js`**: two assertions tied to opencode's old,
+ now-false declaration updated - the "healthy install" test no longer expects `"not
+ covered: opencode"`, and the test built specifically to exercise `render.js`'s `limitation`
+ fallback against a real (non-stubbed) declaration is removed, since opencode was the one
+ declaration that fit it and no longer does; the synthetic stub test right beside it already
+ covers the same code path and is untouched.
+- **`cli/src/domain/tools/ai/opencode.ts`**: `telemetryJournalHost: "opencode"` added, and
+ the stale `telemetryLocalRead.limitation` text removed - caught by
+ `cli/tests/domain/tools/registry-conformance.unit.test.ts`'s two disagreement tests (the
+ same pin the phase 4 addendum exercised for Cursor, in the opposite direction: there the
+ plugin was wrong and reverted to match the CLI; here the plugin's new `true` is what the
+ live sweep proved, and the CLI's stale `undefined` was what needed to catch up). Both
+ tests pass after the edit; nothing needed changing on the plugin side a second time.
+
+### Not changed, and why
+
+- **`plugins/aidd-telemetry/hooks/lib/step-starts.js`** and **`file-writes.js`**: untouched,
+ per the explicit instruction not to touch `step-starts.js`, and because this phase's scope
+ is the two events named in the architecture projection - a session begins, a turn ends -
+ not task-file attribution. `taskAttributable` stays `false` for OpenCode on both sides
+ (plugin and CLI), consistent with `WRITTEN_PATH_EXTRACTOR_BY_HOST` never gaining an
+ `opencode` entry.
+- **The actual install route.** `cli/src/domain/capabilities/plugins-capability.ts` (off
+ limits - another agent's Codex work), `cli/src/application/use-cases/plugin/**` (same),
+ and `cli/src/application/use-cases/framework/strategies/tool-contracts.ts` (in scope, but
+ not touched) all still produce the skip-and-warn behaviour issue #676 opens with -
+ `translateFlat`'s `collectHooksSkips` still emits "hooks skipped for opencode" for any
+ plugin, including this one, that ships a `hooks/` directory. Nothing installs
+ `opencode-plugin.js` into a real project's `.opencode/plugin/` yet; every capture in this
+ phase used a hand-copied file in a scratch project, exactly as the earlier phases probed
+ before their own routes existed. Wiring `aidd framework build`/`aidd plugin add` to ship a
+ *second* kind of artefact for OpenCode specifically (JS to be loaded, not JS to be
+ executed - issue #676's own framing) is a new installation mode, scoped by that issue, not
+ by this phase's architecture projection, and is left for whoever picks it up next.
+
+### What `docs/telemetry-limits.md` should say
+
+OpenCode's entry currently reads as one blanket statement about being unjoinable. It should
+now separate three things phase 5 measured independently:
+
+1. **The extension surface exists and the id is seen.** A JS module placed at
+ `.opencode/plugin/*.js`, written as genuine ESM (OpenCode's loader does not run a
+ CommonJS `module.exports` file - measured, not assumed, across three real sessions plus
+ free reproduction), sees the session's own id on `session.created`'s `event.properties.
+ info.id` - the same id `opencode export ` and the existing local-read reader already
+ key on. A sweep of the run journal reaches an OpenCode session nobody named by hand.
+2. **The reader was already correct; only the join was missing, and now isn't.** Local read
+ (`opencode export --sanitize`) is unchanged and was never in question - it already
+ reconciled token counters. `journalAttributable` is now `true`, on both the plugin's own
+ `readers.js` and the CLI's `opencode.ts`, pinned to agree by
+ `registry-conformance.unit.test.ts`.
+3. **The framework does not install this yet.** The join above was proven with a
+ hand-placed file, the same way Cursor's flat-hook route was proven before `cursor:flat`
+ existed as a shipped target. `aidd framework build` still has no route that ships a
+ loaded-not-executed JS module for OpenCode - it still emits the same "hooks skipped for
+ opencode" warning it always has, for `hooks/opencode-plugin.js` exactly as for anything
+ else under `hooks/`. Until that install mode exists, `journalAttributable: true` is a
+ true statement about what the mechanism does when present, not about what a fresh
+ `aidd plugin add` produces today.
+4. **The plumbing for both lines is proven; only one of the two triggering events is.**
+ `journal.js turn-end` was run directly, free, with a synthetic `{tool:"opencode",
+ session_id, cwd}` payload matching one `session-start` had already written for, and it
+ appended `turn_end` to that exact run file - the same dispatch `hooks/opencode-plugin.js`
+ drives from `session.idle`. What was not captured is OpenCode's own `session.idle` firing
+ with a real id: exercising it needs a billed message, on budget grounds. It shares the
+ identical `event` callback and an identically-shaped `properties.sessionID` field
+ (`@opencode-ai/plugin`'s own types) that `session.created` already proved delivers real
+ data, so this is a small, named gap - the trigger, not the mechanism.
+5. **A silent failure mode worth naming.** `hooks/opencode-plugin.js` spawns `node
+ journal.js` and does not check the result - deliberately, matching journal.js's own "exit
+ 0 no matter what" contract (a measurement layer must not break a session). That means a
+ plugin shipped without `journal.js` and `lib/` beside it, or run where `node` is not on
+ `PATH`, journals nothing and reads identically to "no sessions ran" - the same silent
+ failure that cost three of this phase's own iterations (a `process.execPath` bug that
+ produced no error anywhere) before `--print-logs`'s own event log was used to catch it.
+ Nothing to fix in the code for this alone; a consumer debugging "opencode never appears"
+ needs to know journal.js's own exit code is not where that failure would show.
+
+### Restoration
+
+Everything scratch lived under `/private/tmp/.../scratchpad/opencode-probe` and
+`/private/tmp/.../scratchpad/opencode-turnend-probe` - both removed after this phase. The
+`turn-end` plumbing check above (Finding 4) used a synthetic id, `ses_turnend_test`, piped
+directly into `hooks/journal.js` from a shell - not a real OpenCode session; called out here
+so nothing in this document reads a synthetic id as a live capture.
+
+One process was left over from the second billed session (a `opencode run` invocation
+processing `--print-logs` through a piped `tail`, which never received the EOF a real
+terminal would have sent it) and was still running, 22 minutes later, when this phase's
+other work finished - found via `ps aux | grep opencode` during cleanup and killed. No other
+`opencode` or `opencode serve` process was left running; every `serve` instance launched
+during Findings 2 and 3 and the final proof was killed immediately after the capture it was
+started for. `~/.opencode`, `~/.config/opencode`, and `~/Library/Application Support/
+orca/opencode-hooks` were read from (to find the plugin type definitions and the one real
+reference plugin already installed there) but never written to. Budget: 3 of 3 real sessions
+used, all three spent before the extension surface's export-shape requirement was
+understood; every capture after that point was free.
+
+## Phase 6 — Cursor: hooks delivered where they fire, both modes closing a turn
+
+### Budget
+
+Two real `cursor-agent` invocations against the live API, of a budget of three. The third
+was not used: both sessions were decisive and consistent with Phase 4's findings, and the
+guidance was to diagnose before retrying, not to spend the budget confirming a clean result.
+
+### Task 3, checked first: already done
+
+`plugins/aidd-telemetry/hooks/lib/repo.js`'s `CWD_READER_BY_HOST` already carries
+`cursor: (payload) => firstGitWorkspaceRoot(payload.workspace_roots)`, resolving the first
+`workspace_roots` entry that is itself a git repository rather than assuming index zero -
+landed via the `2026_08_20_step-boundaries` tree (`git log`: commit `7356c4ec`), covered by
+`scripts/__tests__/aidd-telemetry-journal.test.js` (`"readCwd: every host but Cursor reads
+payload.cwd directly; Cursor reads the first workspace_roots entry that is a git
+repository"`, plus the multi-root and no-git-root cases). Zero lines changed for this task.
+
+### Task 1: hooks now land in `.cursor/hooks.json`, not the plugin directory
+
+Plugin-scope hooks were the only route the framework ever installed a Cursor hook through,
+and Phase 4 measured that route firing nothing. Rather than guess a new plugin-scope fix,
+this task moves the *destination*: `cursor.ts`'s `plugins` capability gained
+`hooksDestination: "project"` (`cli/src/domain/capabilities/plugins-capability.ts`), a new
+per-capability field distinct from `installScope` - skills, agents, commands and mcp are
+untouched and still materialize under `~/.cursor/plugins/local//`, exactly as before.
+
+`ModeBFlatMaterializationTranslator` (`cli/src/application/use-cases/plugin/translator/
+mode-b-flat-materialization-translator.ts`) reads that field: when it is `"project"`, the
+plugin's `hooks/` files are stripped out before the generic native translation runs
+(`withoutHooks`), and a new side channel - `materializeProjectHooks`, mirroring the existing
+`resolveMcp`/`mergeOpencodeMcpEntries` pattern for OpenCode's mcp merge - merges the plugin's
+`hooks/hooks.json` into the project's own `.cursor/hooks.json` instead, via a new pure
+module, `cli/src/domain/formats/cursor-hooks-project-merge.ts`. That module rewrites
+`${CLAUDE_PLUGIN_ROOT}/hooks/` to `./.cursor/hooks//` (the same destination
+`aidd framework build --target cursor --flat` already computes via `genericFlatHooksScriptPath`,
+reused directly rather than re-derived) and then calls the existing `mergeCursorFlatHooks` -
+so the install route and the framework-build route now produce byte-identical shapes through
+one shared merge function. Hook scripts (`journal.js`, `lib/*`) are copied verbatim to
+`.cursor/hooks//` alongside the manifest.
+
+Hooks are deliberately **not** added to the plugin's `Plugin.files` record: that record is
+join()'d against the plugin's own `baseDir` (`~/.cursor/plugins/local//`) by both
+`writePluginFiles` and `plugin remove`'s `deleteOldFiles`, and a project-scope path doesn't
+live there. `mcp.json` remains tracked as before.
+
+Proof, from the real CLI (`aidd ai install cursor`, `aidd telemetry on --endpoint ... --yes`,
+then `aidd plugin install /plugins/aidd-telemetry --tool cursor --scope user --yes`,
+against a throwaway git-initialized project under `/private/tmp`):
+
+```
+.cursor/hooks.json:
+{
+ "version": 1,
+ "hooks": {
+ "sessionStart": [{ "command": "node ./.cursor/hooks/aidd-telemetry/journal.js session-start" }],
+ "stop": [{ "command": "node ./.cursor/hooks/aidd-telemetry/journal.js turn-end" }],
+ "sessionEnd": [{ "command": "node ./.cursor/hooks/aidd-telemetry/journal.js turn-end" }],
+ "postToolUse": [{ "command": "node ./.cursor/hooks/aidd-telemetry/journal.js tool-used" }]
+ }
+}
+
+.cursor/hooks/aidd-telemetry/: journal.js, lib/host.js, lib/step-starts.js, lib/file-writes.js,
+ lib/record.js, lib/repo.js, opencode-plugin.js
+```
+
+`opencode-plugin.js` rides along: it sits beside `journal.js` under the plugin's own `hooks/`
+today (another agent's in-flight, uncommitted work on this same tree), and the copy step -
+matching `writeFlatHooksScripts` in the framework-build route, which has the identical
+"everything under hooks/ but its own manifest" rule - carries it verbatim like every other
+script. Unused by Cursor, harmless, not worth a special case for one file the shared route
+already treats the same way.
+
+`~/.cursor/plugins/local/aidd-telemetry/` after install: `skills/00-init/`, `skills/01-cost/`,
+`skills/02-check/` only - no `hooks.json`, no `hooks/`. Nothing left in a directory Cursor
+never reads.
+
+**Declared, not built:** the marketplace-sourced install path
+(`BuiltTreeMaterializationTranslator`, taken when `aidd plugin install ` names a
+marketplace plugin rather than a local path) still copies from `builtDir/plugins//` -
+still plugin-scoped, still unfixed. The proof above went through the local-source install,
+the same command Phase 4 used and the one `docs/telemetry-limits.md` should describe; the
+marketplace route is untouched, per the instruction not to restructure `installScope` or
+`pluginsDir`, and is named here rather than silently left inconsistent. Likewise, `plugin
+remove` does not yet unmerge a plugin's contribution out of `.cursor/hooks.json` or delete its
+`.cursor/hooks//` scripts - removing the telemetry plugin today leaves both behind.
+Neither gap is exercised by any acceptance criterion this phase was handed; both are flagged
+for whoever picks up uninstall parity next, not fixed here.
+
+### Task 2: which event closes a turn, in each mode - established by running both
+
+Phase 4's addendum had one observation of each mode (interactive: `stop`, twice, from a
+force-killed session; headless: `sessionEnd`, from a different, older probe in issue #680)
+and said explicitly that one of each was not enough. This phase ran both fresh, through the
+real install above, each project instrumented with an observer entry appended to every one of
+Cursor's seven documented hook events (`sessionStart`, `beforeSubmitPrompt`, `preToolUse`,
+`postToolUse`, `beforeReadFile`, `stop`, `sessionEnd`, `subagentStop`), each writing its own
+name to a log file - alongside the installed `journal.js` commands, not replacing them.
+
+**Headless** (`cursor-agent -p "..." --force --trust`): fired `sessionStart`, `preToolUse`,
+`beforeReadFile`, `postToolUse`, `sessionEnd`. Did **not** fire `stop`, `beforeSubmitPrompt`,
+or `subagentStop`. Journal:
+
+```
+{"type":"session_start", ..., "run_id":"01M0M3WK6AYQCYEXKJCMAB30XA", ...}
+{"type":"turn_end","at":"2026-08-22T06:52:51Z"}
+```
+
+One `turn_end` line, sourced from `sessionEnd` alone (`stop` never fired). Confirms, on a
+current Cursor build (`2026.08.11-e8db854`) and the real production install path, what Phase
+4 could previously only infer from an older probe.
+
+**Interactive** (`expect`-driven pty, no `-p`, exited cleanly via `/exit` rather than being
+force-killed): fired `sessionStart`, `beforeSubmitPrompt`, `preToolUse`, `beforeReadFile`,
+`postToolUse`, `stop` - exactly once. Did **not** fire `sessionEnd` or `subagentStop`. Journal:
+
+```
+{"type":"session_start", ..., "run_id":"01M0M3XHZQD29SKP51C5RRMV2T", ...}
+{"type":"turn_end","at":"2026-08-22T06:53:26Z"}
+```
+
+Again one `turn_end` line, this time sourced from `stop` alone. The double-`stop` seen in
+Phase 4's addendum (`status: "error"` then `status: "aborted"`) came from that session being
+torn down mid-shutdown, not from `stop` firing twice in the ordinary case - a clean `/exit`
+here produced exactly one.
+
+**Neither mode fired both events in this pass** - `stop` and `sessionEnd` are mode-exclusive
+in every session observed to date, not merely likely to be. The design does not depend on
+that holding forever, though: `CURSOR_EVENT_MAP` in `cli/src/domain/formats/
+flat-hooks-merge.ts` now fans `Stop` out to `["stop", "sessionEnd"]` - both Cursor events
+carry the identical `journal.js turn-end` command, so a session that fired both would simply
+produce two `turn_end` lines, which `record.js`'s reader already tolerates (proven in Phase
+4's addendum, two real `stop` firings, one run). No change to `journal.js` or
+`HOOK_EVENT_NAME_TO_CANONICAL` was needed: the command's own argv (`turn-end`) is checked
+before `hook_event_name` is ever consulted, so it makes no difference which of the two
+Cursor spells the event.
+
+`plugins/aidd-telemetry/hooks/hooks.json` (the shared source every host's build reads) was
+**not** changed. Fanning out inside `CURSOR_EVENT_MAP` reuses the existing `Stop` source key;
+adding a literal `SessionEnd` key there instead would have leaked into Claude's, Codex's, and
+Copilot's own `--flat` build output too (`mergeClaudeSettingsHooks`, `mergeCodexFrameworkHooksJson`,
+and `flattenCopilotHooksShape` all copy every key through undiscriminated), handing three
+hosts that have no such event a dead hook entry - exactly the "a tool's own vocabulary...
+never leaking into a shared shape" decision this plan already committed to.
+
+### Repeat-install duplication, named rather than hit by accident
+
+`mergeCursorFlatHooks` appends; it has no notion of "this plugin already contributed this
+entry" the way `mergeOpencodeMcp` does by key. Installing the same plugin twice into one
+project without removing it first would double every command in `.cursor/hooks.json`. Both
+proof sessions above used a fresh `/private/tmp` project with exactly one install each, and a
+`cat .cursor/hooks.json` right after install (shown above) confirmed one entry per event
+before either session ran. Not exercised by this phase's acceptance criteria; named as a gap
+for the same uninstall-parity follow-up as the marketplace-route and `plugin remove` gaps above.
+
+### What `docs/telemetry-limits.md` should say about Cursor
+
+The journal route is no longer uncovered. Replace "Cursor's plugin-scope hook... was never
+observed firing" with: installing the telemetry plugin for Cursor through `aidd plugin
+install --tool cursor --scope user` (the local-source route; the marketplace-sourced
+route is not yet fixed, see Task 1 above) now delivers hooks into the project's own
+`.cursor/hooks.json` - the destination measured, across both Phase 4 and this phase, to
+actually fire - rather than the plugin-scope directory Cursor's native install writes
+everything else to. A real interactive session and a real headless session both produced a
+run file naming Cursor's own conversation id and exactly one `turn_end` line: interactive
+sessions close the turn on `stop`, headless sessions close it on `sessionEnd`, and the
+install subscribes to both so neither mode is silently unmeasured. Local read and export
+remain uncovered for the reasons already stated in that section (Cursor writes no token count
+in any file it produces; export is an Enterprise team setting nobody here can turn on) -
+unchanged by this phase, journaling and reading are independent capabilities and only the
+first moved.
+
+### Restoration
+
+`aidd ai install cursor`, `aidd telemetry on`, and `aidd plugin install ... --tool cursor
+--scope user --yes` were run against two throwaway projects under `/private/tmp` (git-
+initialized, nothing pre-existing to preserve) - not restored, per the established pattern
+that scratch under `/private/tmp` needs no cleanup. Outside the repo, both installs wrote to
+the real `~/.cursor/plugins/local/aidd-telemetry/`, freshly created by this phase (Phase 4's
+own probe had already removed it at the end of that phase); removed after this phase's proof
+was captured. `aidd-context`, `aidd-dev`, `aidd-orchestrator`, `aidd-pm`, `aidd-refine`,
+`aidd-test`, `aidd-ui`, `aidd-vcs` in that same directory are pre-existing on this machine,
+untouched by this phase. No `cursor-agent` process was left running. Budget: 2 of 3 real
+sessions used.
+
+## Phase 7 — delivery: what was proven by hand, an install now produces
+
+### Budget
+
+Two real `opencode run` invocations against the live API, of a budget of two - both spent on
+provider/model resolution failures before either reached a model call, so neither is real
+spend in the billing sense, but both are real spend against the session budget and neither
+settled the question they were meant to. Zero `cursor-agent` sessions: Task 2 and Task 3 are
+about *where an install writes*, provable by running the real `aidd` CLI and reading the
+filesystem - the question of whether Cursor's hooks fire once installed there was already
+settled, twice, in Phases 4 and 6.
+
+1. `opencode run` against a fresh scratch `$HOME` with no provider config: silently defaulted
+ to `opencode/big-pickle`, a free hosted tier, and hit `FreeUsageLimitError` (HTTP 429) on
+ every retry for several minutes before being killed. Not this repo's bug - a probe
+ environment gap (no model specified, no config to default it) - but it consumed real
+ session-budget time without ever reaching the code under test.
+2. `opencode run -m anthropic/claude-haiku-4-5-20251001` against the real `$HOME` (copying
+ the real `auth.json`'s Anthropic OAuth alone was not enough - `opencode models` still
+ listed only free tiers even under the real `$HOME`, and every explicit `anthropic/...`
+ model id drawn from `~/.cache/opencode/models.json` - `claude-sonnet-4-5`,
+ `claude-haiku-4-5-20251001` - came back `Model not found`, a provider/catalog mismatch
+ this session could not resolve.
+
+Per the guidance to diagnose before retrying and to report an exhausted budget rather than
+keep guessing: this is named as a real gap below, not papered over.
+
+### Task 1: OpenCode gets a runtime it can load, delivered - proven by installing for real
+
+`PluginsCapability`'s `FlatPluginsParams` gained a `FlatHooksSupport` union
+(`cli/src/domain/capabilities/plugins-capability.ts`), mirroring native mode's own
+`HooksSupport`: `{acceptsHooks: true, flatHooksDir}` or `{acceptsHooks: false,
+hooksUnsupportedReason}`. `opencode.ts` now declares the first: `acceptsHooks: true,
+flatHooksDir: ".opencode/plugin/"` - the exact directory OpenCode's loader scans
+(`{plugin,plugins}/*.{ts,js}`, non-recursive, measured in Phase 5).
+`PluginContentTranslator.translateFlat` (`plugin-content-translator.ts`) gained
+`flatHooksFiles`: every file under a plugin's `hooks/` but its own `hooks.json` manifest -
+the manifest describes a shape OpenCode never reads - is carried verbatim into
+`flatHooksDir`, the same "carry the script, translate the prose" rule native mode already
+follows. `collectHooksSkips` needed no change: it already reads `acceptsHooks` off the
+capability, so a tool that now accepts hooks stops emitting a skip without any conditional
+being touched.
+
+Proof, from the real CLI (`aidd ai install opencode`, `aidd plugin install
+/plugins/aidd-telemetry --tool opencode --scope project --yes`, against a throwaway
+git-initialized project under `/private/tmp`):
+
+```
+.opencode/plugin/: journal.js, opencode-plugin.js, lib/host.js, lib/step-starts.js,
+ lib/file-writes.js, lib/record.js, lib/repo.js
+```
+
+No `hooks.json`. No skip warning printed (`plugin install` emitted none). Matches exactly
+what `docs/telemetry-limits.md` should now say the OpenCode install route delivers - the same
+directory, the same files, the hand-placed proof from Phase 5 turned into what a fresh
+install produces.
+
+**A real bug, found only by running it, not by reading it.** `opencode-plugin.js`'s
+`runJournal` passed `JOURNAL_SCRIPT` - a `URL` object built with `new URL("./journal.js",
+import.meta.url)` - directly into `spawnSync("node", [JOURNAL_SCRIPT, event], ...)`. Node
+stringifies a non-string argv element, giving `"file:///.../journal.js"` - and `node
+` is **not** a valid script invocation: Node's CLI resolves a bare path
+argument as a CommonJS specifier relative to its own `cwd`, not as a `file://` URL, so the
+spawned process died with `MODULE_NOT_FOUND` on a mangled path
+(`/file:/.../journal.js`, one slash swallowed by path normalization) - silently, every
+time, because `journal.js`'s own "exit 0 no matter what" contract means `runJournal` never
+checks `spawnSync`'s result. Direct invocation of `journal.js` by its real path always
+worked (which is how Phase 5's own "Finding 4" free proof of the `turn-end` plumbing passed
+- it piped a synthetic payload straight into `journal.js` by path, never through
+`opencode-plugin.js`'s own `runJournal`, so this bug had no test surface until a real
+delivered file was actually run). Fixed with `fileURLToPath`. A new regression test,
+`scripts/__tests__/opencode-plugin.test.js`, imports the delivered file as ESM, calls
+`AiddTelemetry` with a synthetic `session.created` then `session.idle` event, and asserts a
+`session_start` then `turn_end` line - confirmed to fail on the unfixed code (reverted and
+re-ran by hand) and pass on the fix.
+
+**What is proven, and what is not.** The delivery is proven: the right files land in the
+right place, with no skip warning, via the real CLI. The plugin's own dispatch plumbing is
+proven, directly: calling `AiddTelemetry`'s returned `event` handler with a synthetic
+`session.created` then `session.idle` event correctly spawns `journal.js` and writes both
+lines. **What is not proven: that OpenCode's own live process actually calls that handler**,
+end to end, without anything synthetic in the loop. Three independent free probes (`opencode
+serve` + a direct `curl -X POST /session`, matching Phase 5's own technique exactly - once in
+a project with competing plugins, once in a project isolated to a single probe file replicated
+verbatim from Phase 5's own successful capture) all showed the plugin's module loaded and its
+exported function *called* (confirmed via a synchronous `fs.appendFileSync` at the top of the
+returned `event` callback), but the callback was never invoked for `session.created`,
+`session.updated`, or any bus event that followed - across two sessions created on the same
+long-running server, not merely a first-event race. This contradicts Phase 5's own "Finding
+2" capture of the identical event under the identical technique.
+
+Reading the installed `opencode` binary's own decompiled plugin-loading code
+(`strings`/manual trace, not guessed) shows the mechanism *should* work: loaded plugins are
+pushed into an array `W` before a `subscribeAll()` wildcard listener is forked
+(`$.subscribeAll().pipe(N1.runForEach((j)=>{for(let D of W)D.event?.({event:j})}),
+L.forkScoped)`), and the log ordering confirms that fork happens before any session activity.
+The one structural detail that fits the observation and that this session could not verify
+directly: `forkScoped` ties the listener's lifetime to a *scope*, and if that scope belongs
+to the bootstrapping HTTP request (or to the app instance only while a client stays
+connected) rather than to the server process itself, a bare `POST /session` with no
+persistently-connected client could have its listener torn down with nothing ever having
+had the chance to deliver an event through it - which a genuine `opencode run` (a real,
+a real, persistently-connected session) would not exhibit, since the client stays attached for the run's
+duration. This is a plausible, bytecode-grounded theory, not a confirmed one: settling it
+needs exactly the real, connected session this phase's budget could not complete (see
+Budget above). Named here, not asserted as fixed, and not silently dropped.
+
+**What `docs/telemetry-limits.md` should say**, updated for this: the framework now installs
+`hooks/opencode-plugin.js` (with `journal.js` and `lib/` beside it) into `.opencode/plugin/`
+via `aidd plugin install --tool opencode` - the "framework does not install this yet" line
+from Phase 5 is no longer true and should go. What should replace it: the delivery is
+proven; the plugin's own dispatch code is proven directly; whether OpenCode's live event bus
+actually reaches an installed plugin's handler in an ordinary run is *not yet proven by a
+live session* - Phase 5's own capture of this exact thing is now in question, not confirmed,
+pending a session with a persistently-connected client (a real `opencode run`, not a bare
+`curl POST /session`).
+
+### Task 2: a marketplace install does what a local one does - proven by installing for real
+
+`ModeBFlatMaterializationTranslator`'s `materializeProjectHooks` logic moved into a new
+shared class, `ProjectHooksMaterializer`
+(`cli/src/application/use-cases/plugin/translator/project-hooks-materializer.ts`), along with
+the `withoutHooks` helper that strips `hooks/` from a `PluginDistribution` before the generic
+native translator sees it. `BuiltTreeMaterializationTranslator` - the marketplace-sourced
+route, taken when `aidd plugin install --from ` resolves a registered
+marketplace - now calls the same `ProjectHooksMaterializer.materialize` on the *original*
+`PluginDistribution` (not the built tree, which still ships hooks/hooks.json plugin-scoped -
+the marketplace build never learned the project-scope route exists, and fixing that build
+target was not this task) when the tool's own capability declares `hooksDestination ===
+"project"`, and strips `/hooks/` out of the built-tree files it copies into the
+plugin-scoped directory. Both routes call the identical function on the identical input;
+neither route derives the destination itself.
+
+Proof, from the real CLI against a throwaway project under `/private/tmp`: a scratch
+marketplace (`.claude-plugin/marketplace.json` naming `aidd-telemetry` by a relative
+`./plugins/aidd-telemetry` source, matching the schema `assets/schemas/claude-marketplace-
+manifest.json` requires - `name`, `owner`, and each plugin's `source` as a *string*, not the
+`{kind,path}` object shape some other install routes accept), registered with `aidd
+marketplace add`, then `aidd plugin install aidd-telemetry --from telemetry-market --tool
+cursor --scope user --yes`:
+
+```
+.cursor/hooks.json: sessionStart, stop, sessionEnd, postToolUse - one entry each, commands
+ naming .cursor/hooks/aidd-telemetry/journal.js
+.cursor/hooks/aidd-telemetry/: journal.js, opencode-plugin.js, lib/*
+~/.cursor/plugins/local/aidd-telemetry/: skills/ only - no hooks.json, no hooks/
+```
+
+Byte-for-byte the same destination Phase 6 proved for the local-source route. The disagreement
+test the task asked for:
+`cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-marketplace-hooks
+.integration.test.ts`, `"both routes write to the destination cursor.ts declares"` - installs
+via `ModeBFlatMaterializationTranslator` and via `BuiltTreeMaterializationTranslator`
+independently, reads the destination `.cursor/hooks.json` path from `cursor.ts`'s own
+`hooksDestination` field rather than hard-coding it, and asserts both routes wrote there and
+neither wrote a `hooks`-containing path under the plugin-scoped directory. Reading the
+declaration rather than comparing the two routes to each other is deliberate: two routes
+regressing to plugin scope *together* would still pass a route-vs-route-only comparison,
+which is exactly the shape of drift issue #698 already produced once.
+
+### Task 3: undo what an install did - proven by installing and removing for real
+
+**Dedup, at merge time.** `mergeCursorProjectHooksJson`
+(`cli/src/domain/formats/cursor-hooks-project-merge.ts`) now strips a plugin's own prior
+contribution before merging its fresh one - `stripPluginHookEntries`, matched by a
+plugin-unique marker (`.cursor/hooks//`, which every command this route ever writes
+already contains, since scripts land under that exact path). Landed in the install-time
+wrapper, not in `mergeCursorFlatHooks` itself, which the phase text names as the culprit:
+`mergeCursorFlatHooks` is also what `aidd framework build --target cursor --flat` calls, and
+a fresh build writing to a fresh `outDir` every run has no repeat-accumulation exposure to
+fix - only the install route, which merges into a *persistent* project file across separate
+invocations, does. Also rejected: an `mcpEntries`-style tracked-contribution map (the pattern
+`mergeOpencodeMcp` uses). MCP server names carry no plugin identity of their own, so that
+tracking is load-bearing there; every Cursor hook command this route writes already embeds
+its owning plugin's name in its own path, making a second, persisted "what did I contribute
+last time" record redundant.
+
+**Unmerge, on remove.** `unmergeCursorProjectHooksJson` (same file) strips one plugin's
+entries with the identical marker and no other input - `PluginRemoveUseCase.removeProjectHooks`
+(`plugin-remove-use-case.ts`) calls it for every tool whose `PluginsCapability` declares
+`hooksDestination === "project"`, then deletes `.cursor/hooks//` outright
+(`cursorProjectHooksScriptDir`, a new export). Both destinations are recomputed from
+`pluginName` alone - no new field on `Plugin`/`Manifest` was needed, because the destination
+was always deterministic from the name, the same fact the dedup marker above already relies
+on.
+
+Proof, from the real CLI, same scratch project as Task 2, extended to two plugins
+(`aidd-telemetry` and `aidd-context`, both shipping `hooks/`) installed side by side:
+
+```
+after both installed: .cursor/hooks.json sessionStart has two entries (aidd-telemetry,
+ aidd-context); .cursor/hooks/ has both plugins' own subdirectories
+aidd plugin remove aidd-telemetry --tool cursor:
+ .cursor/hooks.json sessionStart now has exactly aidd-context's entry - aidd-telemetry's
+ is gone, aidd-context's is untouched
+ .cursor/hooks/aidd-telemetry/ is gone; .cursor/hooks/aidd-context/ still exists
+ ~/.cursor/plugins/local/aidd-telemetry/ is gone entirely (mcp.json/skills, tracked in
+ Plugin.files as before)
+```
+
+A plain repeat `aidd plugin install aidd-telemetry ...` (no `--replace`, no prior remove)
+throws `DuplicatePluginError` before reaching any translator - the manifest layer already
+refuses a second install by name, for every route, not something this phase changed. The
+real, CLI-reachable "install twice" path is `plugin remove` then `plugin install` again -
+proven above, one copy, because remove already cleared the old one before the new merge ran.
+The path the dedup logic itself exists for - `PluginAddUseCase`'s internal `replace: true`
+(used by `aidd setup`'s idempotent re-run, not exposed as a `plugin install` flag) merging a
+second time *without* an intervening remove - is proven by running the actual production
+`ModeBFlatMaterializationTranslator.addPlugin` twice against one manifest (with the manifest
+entry dropped, not the filesystem, between calls - exactly what `replace: true` does):
+`remove-plugin-cursor-hooks-mcp.integration.test.ts`, `"installing the same plugin twice
+leaves one copy in .cursor/hooks.json"`. This is real production code executing on each call,
+not a hand-derived read of the merge function, but it is not a CLI-level repro - `aidd
+setup`'s specific re-run flow was not separately exercised end to end within this phase's
+budget.
+
+### What `docs/telemetry-limits.md` should say, updated for this section
+
+Cursor's journal-route entry (Phase 6) should drop "the marketplace-sourced route is not yet
+fixed" - both routes now agree, proven above. Nothing in the local-read or export sections
+changes; this phase moved delivery and removal only.
+
+### Restoration
+
+**Repo-external state.** `~/.cursor/plugins/local/` outside the repo was never touched -
+every Cursor CLI invocation in this phase ran with `HOME` pointed at a scratch directory
+under `/private/tmp`, confirmed after the fact (`ls ~/.cursor/plugins/local/` still shows
+only the same pre-existing plugins Phase 4/6 listed, untouched). OpenCode's install proof
+also ran under a scratch `$HOME` for the delivery check. The two failed `opencode run`
+sessions (Budget, above) ran against the *real* `$HOME` after the scratch one turned out not
+to carry enough provider configuration to resolve a model - this wrote at most a stale
+session row into `~/.local/share/opencode/opencode.db` pointing at a since-deleted `/private/
+tmp` project (normal residue of ordinary `opencode` use on this machine, not cleaned
+separately) and read, never wrote, `~/.local/share/opencode/auth.json`. No `opencode` or
+`cursor-agent` process was left running (checked via `ps aux` after each phase of testing).
+Everything else - both scratch projects, the scratch marketplace, the scratch `$HOME`
+directories - lived under `/private/tmp` and was removed after this phase's proofs were
+captured.
+
+**In-repo.** `plugins/aidd-telemetry/hooks/opencode-plugin.js` gained the `fileURLToPath` fix
+described above (a real, load-bearing bug fix, not a probe artifact) and stays. Budget: 2 of 2
+`opencode run` sessions used, neither reaching a model call (see Budget); 0 of an unbudgeted-
+but-unneeded `cursor-agent` allowance used, per the instruction that Tasks 2 and 3 needed
+filesystem inspection after a real CLI run, not a live Cursor session.
+
+
+## Adjudication — why phases 5 and 7 disagreed about OpenCode, and what is true
+
+Phase 5 captured the plugin's `event` handler receiving `session.created`. Phase 7 ran three
+probes, one replicating phase 5 verbatim, and the handler was never invoked. Both reports are
+accurate about what their author observed, and the reason is a property of OpenCode nobody had
+named.
+
+Reproduced here with the production plugin instrumented to record two moments — when its module
+is loaded, and when its factory is called:
+
+```
+$ opencode serve --port 39918 # after boot
+(no trace)
+
+$ curl -X POST /session # first session
+MODULE_LOADED
+FACTORY_CALLED ["client","project","worktree","directory","experimental_workspace","serverUrl","$"]
+ -> no run file
+
+$ curl -X POST /session # second session
+ -> aidd_docs/runs/01M0M767…__ses_fd78ce10fffeDAwyK6AVv1i24h.jsonl
+ {"type":"session_start","tool":"opencode","vendor_id":"ses_fd78ce10fffeDAwyK6AVv1i24h"}
+```
+
+**The plugin is loaded lazily, by the very request that creates the first session.** Nothing is
+loaded at server boot. So `session.created` for that first session is published before a handler
+exists to receive it, and it is missed — silently, since nothing failed. Every session after it,
+in the same server process, journals correctly.
+
+That reconciles the two reports exactly. Phase 5 read two sessions from its sweep, so at least one
+of them came after the plugin was live. Phase 7 started a fresh server for each probe and only ever
+observed the first session of each.
+
+### What follows
+
+`journalAttributable: true` stands: a sweep does reach OpenCode sessions nobody named by hand, which
+is what the flag promises. The limitation is narrower and needs saying plainly: **the first session
+of a server process is not journalled.** It is not a race that a retry fixes — the handler does not
+exist yet — and there is no session identifier in what the factory is handed, so the plugin cannot
+recover it from inside.
+
+One smaller thing the same capture shows: the OpenCode journal line carries `vendor_field: null`
+where every other tool names the field its identifier came from. Worth a line of its own.
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-1.md
new file mode 100644
index 000000000..5bd8e9c1b
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-1.md
@@ -0,0 +1,66 @@
+---
+status: done
+---
+
+# Instruction: A script runs from the tree an install actually carries
+
+## Architecture projection
+
+```txt
+.
+└── scripts/__tests__/
+ └── plugin-install-shape.test.js ✅ every skill script, run from a copy of what ships
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[a plugin's skill script] --> B[copied into a tree holding only what an install carries]
+ B --> C{does it run?}
+ C -->|no| D[fails here, naming the file it could not reach]
+ C -->|yes| E[it will run wherever it lands]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ the plugin's skills, copied alone, with no hooks/ and no repository around them: 5: system
+ section Happy path
+ every script starts and prints its own output: 5: plugin
+ section Edge case - a reach across the boundary
+ a script requiring hooks/ => fails, naming the file: 1: plugin
+ section Edge case - a new script
+ a skill added later is covered without anyone remembering to add it: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Run each script from a copy, not from the source tree
+
+> A script that requires across `hooks/` died at load on a tree that had no `hooks/`, and 310 tests passed over it. Every one of them runs from the repository, where the directory it reached for happens to exist.
+
+1. Copy the plugin's `skills/` alone into a temporary tree — what the flat translation route delivers, nothing else — and run every script it holds.
+2. A script that cannot start fails here, and the message names the file it could not reach. A stack trace is not a test result.
+3. Discover the scripts by walking `skills/*/scripts/`, so a skill added later is covered without anyone remembering.
+
+### `2)` Cover the shape the native route delivers too
+
+> The flat route is not the only one. A native install places the same scripts beside a `hooks/` directory at a different depth, and a relative path that works in the repository can still miss there.
+
+1. Build the second shape from the translator's own output rather than by hand, so the test cannot drift from what installs.
+2. Assert what a person would check: the script runs and prints its own first line, not that a file exists. *The native shape is reconstructed, not observed: the translator is TypeScript with a constructor parameter property and `.js`-extension imports that resolve only against its compiled output, so a node:test file cannot drive it. The reconstruction is derived from the capability rule all four native tools resolve to.*
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------------------------------------------------------------ |
+| 1 | Every skill script starts from a tree holding only `skills/` |
+| 1 | A script reaching outside it fails, naming the file |
+| 1 | A script added later is covered without editing the test |
+| 2 | The same holds for the shape a native install delivers |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-2.md
new file mode 100644
index 000000000..e746df5b8
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-2.md
@@ -0,0 +1,67 @@
+---
+status: pending
+---
+
+# Instruction: Codex says when it is holding a hook back
+
+## Architecture projection
+
+```txt
+.
+├── plugins/aidd-telemetry/skills/02-check/scripts/lib/diagnose.js ✏️ a hook that exists and is not trusted
+└── cli/src/…/plugin-add-use-case.ts ✏️ says at install what still has to happen
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[a plugin with hooks, installed for Codex] --> B[install says the hooks need trusting, and how]
+ B --> C{trusted?}
+ C -->|no| D[the diagnostic says so, rather than blaming the hook]
+ C -->|yes| E[the journal writes]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ a plugin installed for Codex, hooks delivered, never approved: 5: system
+ section Happy path
+ install names what is still required, and the diagnostic agrees: 5: cli
+ section Edge case - after approval
+ the journal writes and the claim reads ok: 1: plugin
+ section Edge case - another tool
+ a tool with no trust gate is told nothing about one: 1: cli
+```
+
+## Tasks to do
+
+### `1)` Say it at install, where a person is already looking
+
+> Four consecutive sessions ran clean and wrote no journal before the flag that bypasses hook trust made the difference visible. Nothing in the install output hinted at it.
+
+1. Installing a plugin that ships hooks for a tool that gates them says so, and says what grants it.
+2. The text comes from the tool's own declaration, so a second gated tool does not need this written twice.
+3. A tool with no such gate is told nothing — a warning that appears everywhere is read nowhere.
+
+### `2)` Let the diagnostic tell "not trusted" from "never fired"
+
+> They are opposite diagnoses today collapsed into one answer, and the wrong one is the one printed.
+
+1. Where the trust state is readable from the tool's own configuration, read it and say a hook exists and is not trusted.
+2. Where it is not readable, say that rather than guessing — an unread state is not an absent one.
+3. Prove it by running Codex with the hook untrusted and then trusted, and reading both answers.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ---------------------------------------------------------------------- |
+| 1 | Installing hooks for a gated tool names what still has to happen |
+| 1 | A tool with no gate is told nothing about one |
+| 2 | An untrusted hook reads as untrusted, never as never fired |
+| 2 | Both answers come from a Codex session that was actually run |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-3.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-3.md
new file mode 100644
index 000000000..53d8054ae
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-3.md
@@ -0,0 +1,69 @@
+---
+status: pending
+---
+
+# Instruction: A Copilot session names the step it is in
+
+## Architecture projection
+
+```txt
+.
+├── plugins/aidd-telemetry/hooks/lib/step-starts.js ✏️ reads the spelling Copilot actually sends
+├── scripts/__tests__/fixtures/ ✅ a captured skill call, not a tool call
+└── docs/telemetry-limits.md ✏️ what Copilot supplies, and what it never will
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[a Copilot session invoking a skill] --> B[the hook receives a tool call]
+ B --> C{is it a skill, and which?}
+ C -->|read| D[a step opens, and the session attributes]
+ C -->|missed| E[today: every record reads unattributed]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ a real Copilot session that invokes a skill, its payload captured: 5: system
+ section Happy path
+ the step opens and the session's records attribute to it: 5: plugin
+ section Edge case - the other payload shape
+ both the canonical and the compat spelling open a step: 1: plugin
+ section Edge case - a tool call that is not a skill
+ no step opens, and nothing is invented: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Capture a skill call, not another tool call
+
+> The capture that fixed recognition used a Bash tool. It settled the field names for a tool call and nothing about a skill call. Two values are still unknown: what the compat builder puts in `tool_name` for a skill, and where the skill's name sits inside `tool_input`.
+
+1. Run a real Copilot session that invokes a skill, and keep its `PostToolUse` payload as a fixture.
+2. Both shapes are in play. If only one can be produced, say which and leave the other unclaimed.
+3. Guessing those two values would fail exactly as the last one did — silently, with a journal that looks healthy.
+
+### `2)` Open the step, and say what a figure still cannot be
+
+> Attribution and a figure are separate promises. This phase can keep the first and must be honest that the second is not coming from Copilot's own files.
+
+1. The step reader recognises whichever spelling the capture carries, alongside the canonical one, and a test fails if either stops being recognised.
+2. A Copilot session running a skill produces a `step_start` naming it, and its records attribute rather than reading unattributed.
+3. `docs/telemetry-limits.md` states what Copilot supplies after this, and why no per-request figure exists in what it writes — the session-granularity route is a separate question, tracked separately.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------------------------------------------------------------- |
+| 1 | A real Copilot skill call is held as a fixture, key set unmodified |
+| 2 | A Copilot session running a skill opens a step naming it |
+| 2 | Both payload shapes open a step, or the unclaimed one is named as such |
+| 2 | A tool call that is not a skill opens nothing |
+| 2 | The limits document says what Copilot supplies, with the capture behind it |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-4.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-4.md
new file mode 100644
index 000000000..f951bd072
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-4.md
@@ -0,0 +1,69 @@
+---
+status: pending
+---
+
+# Instruction: Cursor either runs a plugin hook, or is known not to
+
+## Architecture projection
+
+```txt
+.
+├── cli/src/domain/formats/flat-hooks-merge.ts ✏️ only if a probe says the mapping is what is wrong
+├── docs/telemetry-limits.md ✏️ what Cursor does, from a session
+└── aidd_docs/tasks/…/measurements.md ✏️ the probe, whatever it finds
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[a Cursor session] --> B{does a plugin-scope hook fire?}
+ B -->|yes| C[which events, and what closes a turn]
+ B -->|no| D[what registers a plugin, and does anything?]
+ C --> E[the journal writes, or the reason it cannot is named]
+ D --> E
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ a plugin installed for Cursor, hooks declaring every event it names: 5: system
+ section Happy path
+ an interactive session fires them, and the journal writes: 5: plugin
+ section Edge case - headless
+ which events fire without a person, recorded either way: 1: plugin
+ section Edge case - nothing fires
+ the tool is declared uncovered, with the probe as the reason: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Settle whether a plugin's hooks run at all
+
+> Two headless probes fired nothing from plugin scope, while an earlier probe fired five of seven events from a project-scope file. That is a prior question to `stop` versus `sessionEnd`: if plugin hooks never run, mapping the event correctly changes nothing.
+
+1. Probe interactively as well as headless — the difference between them is the first thing to establish, and one run settles both open questions at once.
+2. Find what registers a plugin sitting in Cursor's plugin directory. Nothing in its configuration files named them, which is a finding either way.
+3. Record what fired and what did not, per scope. This is a measurement, and its result may be that Cursor cannot journal.
+
+### `2)` Act on what the probe found, and nothing more
+
+> `CURSOR_EVENT_MAP` maps `Stop` to `stop` and has no entry for `sessionEnd`. Changing that before knowing whether `stop` ever fires would be guessing which of two events is the real one.
+
+1. If plugin hooks fire and `stop` does not, map whatever marks the end of the work — and only after a probe shows the two are not both firing.
+2. If plugin hooks never fire, Cursor is declared uncovered with the probe as its reason, in the same voice the other uncovered tools use.
+3. Either way, `docs/telemetry-limits.md` says what Cursor does, from a session rather than from its documentation.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------------------------------------------------------------ |
+| 1 | What fires under Cursor is recorded per scope, interactive and headless |
+| 1 | What registers a plugin for Cursor is established, or stated as unknown |
+| 2 | A mapping changes only where a probe showed which event marks the end |
+| 2 | Cursor's entry in the limits document cites the session behind it |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-5.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-5.md
new file mode 100644
index 000000000..388559a24
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-5.md
@@ -0,0 +1,66 @@
+---
+status: pending
+---
+
+# Instruction: OpenCode's own session id reaches the journal
+
+## Architecture projection
+
+```txt
+.
+├── plugins/aidd-telemetry/… ✏️ or a plugin-API entry point, if hooks cannot serve
+├── plugins/aidd-telemetry/skills/01-cost/scripts/lib/readers.js ✏️ journalAttributable, once it is true
+└── docs/telemetry-limits.md ✏️ what changed, and what did not
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[an OpenCode session] --> B{does anything see its session id?}
+ B -->|yes| C[a run journal names it, and the figures already readable join]
+ B -->|no| D[readable and unreachable, exactly as declared today]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ a real OpenCode session, whatever surface it offers: 5: system
+ section Happy path
+ the session id is seen and journalled, and its figures join: 5: plugin
+ section Edge case - a sweep
+ a session nobody named by hand is still reached: 1: plugin
+ section Edge case - it cannot be seen
+ the declaration stays false, with the probe as its reason: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Find out what sees an OpenCode session
+
+> `journalAttributable: false` means two things at once: no step from an interval, and a sweep never reaches one of its sessions. Its figures are readable and cannot be tied to anything.
+
+1. Establish what surface OpenCode offers — its plugin runtime is JS modules and a declarative `hooks.json` means nothing to it, which is why the install skips them.
+2. The question is narrow: does anything running inside a session see that session's own identifier. Answer it by running one, not by reading the API.
+3. If it does, the journal gains a fourth tool. If it does not, the declaration stays false and gains a citation.
+
+### `2)` Join it, or say precisely why it stays unjoinable
+
+> The reader already produces figures for OpenCode. Only the join is missing, so this is a small change or an impossible one, and which is not yet known.
+
+1. Where the identifier is seen, journal it in the shape every other tool uses, and let the existing reader join it unchanged.
+2. Flip `journalAttributable` only when a sweep reaches an OpenCode session nobody named by hand — that is what the flag actually promises.
+3. Where it is not seen, the reason in `readers.js` cites the probe rather than describing the API.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ----------------------------------------------------------------------- |
+| 1 | Whether an OpenCode session sees its own id is settled by running one |
+| 2 | If it does, a sweep reaches that session without it being named by hand |
+| 2 | If it does not, the declared reason cites the probe |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-6.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-6.md
new file mode 100644
index 000000000..08ad17a9f
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-6.md
@@ -0,0 +1,81 @@
+---
+status: pending
+---
+
+# Instruction: Cursor's hooks install where Cursor reads them
+
+## Architecture projection
+
+```txt
+.
+├── cli/src/domain/tools/ai/cursor.ts ✏️ its hooks go to the file it reads
+├── cli/src/domain/formats/flat-hooks-merge.ts ✏️ only the event a probe showed marks the end
+└── plugins/aidd-telemetry/hooks/lib/repo.js ✏️ Cursor names its roots differently
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[aidd plugin install, for Cursor] --> B[hooks merged into the project's own .cursor/hooks.json]
+ B --> C[a session runs]
+ C --> D{interactive or headless?}
+ D -->|interactive| E[stop fires, the turn closes]
+ D -->|headless| F[sessionEnd fires, and must close it too]
+ E --> G[the journal names the session and its turns]
+ F --> G
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ the telemetry plugin installed for Cursor, hooks where Cursor reads: 5: system
+ section Happy path
+ an interactive session journals a start and a turn boundary: 5: plugin
+ section Edge case - headless
+ the same session headless closes its turn too: 1: plugin
+ section Edge case - the plugin directory
+ nothing is left in a directory nothing reads: 1: cli
+```
+
+## Tasks to do
+
+### `1)` Deliver hooks to the file Cursor actually reads
+
+> Measured: a plugin-scope `hooks.json` fired nothing across three probes and every loading mechanism, while a project-scope `.cursor/hooks.json` fired and produced a real run file with Cursor's own conversation id. The obstacle was never Cursor.
+
+1. Cursor's hooks go where the `cursor:flat` build target already puts them, while its skills and commands keep the placement they have. One tool, two destinations, because that is what the tool reads.
+2. An install leaves nothing behind in a directory nothing reads — a file that is never loaded is worse than an absent one, because it looks installed.
+3. Do not restructure what is not in the way. `installScope`, `pluginsDir` and the manifest are about skills and commands, and those work.
+
+### `2)` Close a turn in both modes, from what each one fires
+
+> Interactive fires `stop`, observed twice in one session. The one headless probe fired `sessionEnd` and not `stop`. `CURSOR_EVENT_MAP` maps `Stop` and has no `SessionEnd`, so a headless install would journal a start and never a boundary.
+
+1. Establish, by running both, which events fire in each mode. One observation of each is what exists today and it is not enough to choose between them.
+2. Subscribe to whatever closes a turn in each mode. If both fire in one mode, that is not a problem to design around — a run file already carries two `turn_end` lines from two real stops, and the reader tolerates it — but say so rather than discovering it later.
+3. The plugin's own `hooks.json` and the event map change together, or one of them silently does nothing.
+
+### `3)` Read the root the way Cursor names it
+
+> Cursor's payload carries `workspace_roots`, not `cwd`. Every other host uses `cwd`, and the hook reads `cwd`, so the repository resolves by accident or not at all.
+
+1. Resolve Cursor's root from the field Cursor sends, in the same per-host table the other differences already live in.
+2. A host whose spelling is unknown keeps today's behaviour rather than gaining a guess.
+3. This is what made the probe work; without it the rest of this phase journals nothing.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | -------------------------------------------------------------------------- |
+| 1 | Installing for Cursor writes hooks into the file Cursor reads |
+| 1 | Nothing is left in the plugin directory Cursor does not read |
+| 2 | An interactive Cursor session journals a start and a turn boundary |
+| 2 | A headless one does too, from whichever event fires there |
+| 3 | Cursor's repository root resolves from `workspace_roots` |
+| 3 | Every other host's resolution is unchanged |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-7.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-7.md
new file mode 100644
index 000000000..8d7ab9637
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/phase-7.md
@@ -0,0 +1,83 @@
+---
+status: pending
+---
+
+# Instruction: What was proven by hand is what an install delivers
+
+## Architecture projection
+
+```txt
+.
+└── cli/src/
+ ├── domain/models/plugin-content-translator.ts ✏️ OpenCode receives a runtime it can load
+ ├── application/…/built-tree-materialization-translator.ts ✏️ Cursor, from a marketplace too
+ └── application/…/plugin-remove-use-case.ts ✏️ what an install merged, a removal unmerges
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[aidd plugin install] --> B{which tool?}
+ B -->|Cursor| C[hooks merged into the project's own file, from either source]
+ B -->|OpenCode| D[a module its runtime loads, not a manifest it ignores]
+ C --> E[a session journals]
+ D --> E
+ F[aidd plugin remove] --> G[what was merged is unmerged, what was copied is gone]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ the telemetry plugin, installed from a local path and from a marketplace: 5: system
+ section Happy path
+ both sources deliver hooks the tool loads, and a session journals: 5: cli
+ section Edge case - installed twice
+ the second install does not double what the first merged: 1: cli
+ section Edge case - removed
+ nothing merged or copied survives the removal: 1: cli
+ section Edge case - a tool that loads neither
+ told why, and nothing is left behind: 1: cli
+```
+
+## Tasks to do
+
+### `1)` Deliver OpenCode a runtime it can load
+
+> Its journal was proven with a file placed by hand. `aidd plugin add` still says "hooks skipped for opencode", which was the right answer while a declarative manifest was all we had — its loader ignores those, and only runs a genuine ESM export.
+
+1. An OpenCode install delivers the module its loader runs, in the directory its loader scans, instead of skipping the component.
+2. The skip reason stops being a statement that hooks cannot work there. It becomes true or it goes.
+3. Prove it by installing through the CLI and running a session — the hand-placed file proved the mechanism, and this task is about delivery.
+
+### `2)` Make a marketplace install do what a local one does
+
+> Cursor's hooks now reach the project's own file from a local path. From a marketplace they still land in the plugin directory nothing reads — the same failure, one route over, and now the only one left.
+
+1. Both sources deliver to the same destination, decided by the tool's declaration rather than by which translator ran.
+2. A test fails when the two routes disagree about where a tool's hooks go. Two routes drifting is how this ticket started.
+
+### `3)` Undo what an install did
+
+> A merge into a shared file is not a directory that can be deleted. Removing a plugin today leaves its entries in `.cursor/hooks.json` and its scripts beside them, and installing twice appends a second copy of both.
+
+1. Removing a plugin removes what it merged and what it copied, and leaves every other plugin's entries untouched.
+2. Installing the same plugin twice leaves one copy, not two.
+3. Both are proven by installing and removing for real, not by reading the merge.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ---------------------------------------------------------------------- |
+| 1 | An OpenCode install delivers a module its loader runs |
+| 1 | A session after that install journals |
+| 1 | No message claims hooks cannot work there |
+| 2 | A marketplace install puts Cursor's hooks where a local one does |
+| 2 | A test fails when the two routes disagree |
+| 3 | Removing a plugin leaves nothing it merged or copied |
+| 3 | Installing twice leaves one copy |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/plan.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/plan.md
new file mode 100644
index 000000000..f7c869c1c
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/plan.md
@@ -0,0 +1,44 @@
+---
+objective: "Every tool either measures, or states what it cannot measure and why — each backed by a session that was actually run."
+status: pending
+---
+
+# Plan: measurement on every tool
+
+## Overview
+
+| Field | Value |
+| ---------- | ------------------------------------------------------------ |
+| **Goal** | The measurement layer covers five tools, proven one by one |
+| **Source** | [`spec.md`](./spec.md), issues #676 #680 #681 #697 #699 #701 |
+
+## Phases
+
+| # | Phase | File |
+| --- | ------------------------------------------------------ | ---------------------------- |
+| 1 | A script runs from the tree an install actually carries | [`phase-1.md`](./phase-1.md) |
+| 2 | Codex says when it is holding a hook back | [`phase-2.md`](./phase-2.md) |
+| 3 | A Copilot session names the step it is in | [`phase-3.md`](./phase-3.md) |
+| 4 | Cursor either runs a plugin hook, or is known not to | [`phase-4.md`](./phase-4.md) |
+| 5 | OpenCode's own session id reaches the journal | [`phase-5.md`](./phase-5.md) |
+
+Ordered by what each one unblocks, not by difficulty. Phase 1 is first because it is the guard that would have caught the last two defects, and every later phase adds a script it should cover. Phases 2 to 5 are independent of each other.
+
+## Resources
+
+| Source | Verified |
+| --- | --- |
+| A live Claude Code chain, three skills | Journals, reconciles exactly, diagnostic agrees. The reference the others are held against. |
+| A live Codex session | Journals and reconciles. Its hooks are skipped in silence until trusted. |
+| A real `@github/copilot@1.0.80` capture | Three hooks fire; the payload is the `_vsCodeCompat` shape, now recognised. Its skill calls still open no step. |
+| Two headless `cursor-agent -p` probes | No plugin-scope hook fired at all, while a project-scope file fired five of seven events in an earlier probe. |
+| A copied plugin tree with no `hooks/` | A script requiring across that boundary dies at load. 310 tests passed over it; only running from the copy caught it. |
+
+## Decisions
+
+| Decision | Why |
+| --- | --- |
+| A tool is proven by a session that ran, never by its source | Every tool in this layer has been wrong about itself once. Copilot's chain read airtight from its bundle and was one field name off; Codex's token was declared correctly and never checked. Reading is how the last two defects got written. |
+| "Cannot be measured" is a result, with a capture behind it | Four of five tools will not reach the same coverage, and pretending otherwise is met by declaring success. A stated limit a consumer can act on is worth more than a figure they cannot trust. |
+| A tool's own vocabulary is translated at the edge, never adopted inward | Each tool spells session, step and moment differently. The readers already collapse those into one shape; new tools join by extending that translation, not by leaking a fifth spelling into the report. |
+| No phase closes on a green suite alone | 310 specs passed over a script that could not load on one of the five tools. The suite runs from the source tree; installs do not. |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/review.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/review.md
new file mode 100644
index 000000000..cda3294af
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/review.md
@@ -0,0 +1,156 @@
+# Review: measurement on every tool (+ v1 close, + plugin hooks install)
+
+- **Verdict**: blocked
+- **Diff**: `HEAD...working tree` (45 modified, 26 untracked)
+- **Axes run**: code, functional, relevancy
+- **Date**: 2026_08_22
+- **Findings**: 1 critical, 9 warning, 6 minor
+
+## Phases
+
+### plugin-hooks-install — Phase 1 — One place says which variable a tool expands
+
+- [x] Every tool that runs hooks declares the variable it expands — `claude.ts:120`, `codex.ts:255`, `copilot.ts:326`, `cursor.ts:126`; pinned with an `examined !== 0` guard at `plugin-root-token-declaration.unit.test.ts:39-48`
+- [x] The build route substitutes the declared token, with no copy of its own — `tool-contracts.ts:126,179,232,332` now read `.capabilities.plugins.pluginRootToken`; equality pinned at `plugin-root-token-declaration.unit.test.ts:90-94`
+- [x] A tool that runs no hooks declares none, and nothing is substituted for it — `plugins-capability.ts:205` sets `pluginRootToken = null` in flat mode; guarded at `plugin-root-token-declaration.unit.test.ts:51-60`
+- [ ] Codex's and Cursor's declared tokens are ones a running hook resolved — Cursor's was never observed; disclosed in `plan.md`'s callout → `not-applicable`
+- [x] A token that was never measured is declared as such, not as a fact — `copilot.ts:326-328`
+
+### plugin-hooks-install — Phase 2 — A tool that runs hooks receives them
+
+- [x] A plugin installed for Codex carries its hooks — ran `aidd plugin install … --tool codex`: `.codex/plugins/aidd-telemetry/hooks/{hooks.json,journal.js,lib/*}` delivered
+- [ ] A tool that runs no hooks receives none, and states why — no registered tool declares `acceptsHooks: false` any more (all five say `true`), so the branch has no production caller → `not-applicable`; the dead machinery is finding #2
+- [x] No tool's hook support comes from a default — `plugins-capability.ts:189`, `:206`; the field is required by `HooksSupport`
+- [x] An installed hook command names the target tool's own variable — ran both installs: Codex `node ${PLUGIN_ROOT}/hooks/journal.js …`, Claude `node ${CLAUDE_PLUGIN_ROOT}/…`. Cursor's answer deliberately changed in every-tool phase 6 (`node ./.cursor/hooks/aidd-telemetry/journal.js`); the declaration site never says so (finding #10)
+- [ ] The same plugin, built and installed, yields the same hook command — for Cursor they now genuinely differ (build emits `${CURSOR_PLUGIN_ROOT}/hooks/…`, install emits `./.cursor/hooks/…`) and nothing invokes the build route to compare → `fix`
+- [x] A script beside a hook arrives byte-for-byte, its plugin root untouched — verified on the live Codex install; `installed-hook-resolves.unit.test.ts:105-111`
+- [x] A skill locates its own script after install, on every tool it was installed for — ran the action's own `find` line against a real Cursor and a real OpenCode install: both resolved (`~/.cursor/plugins/local/…/02-check/scripts/telemetry-check.js`, `./.opencode/skills/aidd-telemetry/02-check/…`). Copilot's root is still declaration-derived only
+- [x] Every other `${...}` variable survives translation unchanged — holds by construction (`plugin-root-token-rewrite.ts:26` replaces one literal); still no test
+- [ ] No document names a token that differs from the one the tool declares — `build-contract.ts:89` still lists `${COPILOT_PLUGIN_ROOT}`, which no tool declares → `fix`
+
+### plugin-hooks-install — Phase 3 — An installed hook is proven to resolve
+
+- [x] Every installed hook command resolves to a file that exists — `installed-hook-resolves.unit.test.ts:78-93`, non-empty guard at `:85`
+- [x] An unexpanded variable fails the check, naming the tool — `:95-103`
+- [x] The same install covers a hook and a script beside it — `:105-111`
+- [ ] Both routes deliver hooks exactly when the tool runs them — `:134-146` drives the translator, not `writeHooks`; and the assertion is now trivially true because every tool sets `acceptsHooks: true` → `fix`
+- [ ] The same hook command comes out of either route — `:126` still recomputes the build side as `rewritePluginRootToken(HOOKS_JSON, token)` instead of invoking it → `fix`
+- [ ] A component missing from one route fails, naming it — no test compares the two routes' delivered file sets → `fix`
+- [ ] Every tool's hook support is documented, including those with none — `docs/ARCHITECTURE.md:45-54` is now false for OpenCode and Cursor → `fix`
+
+### telemetry-v1-close — Phase 1 — Copilot's own payload is the one we recognise
+
+- [x] A real Copilot payload is held as a fixture, key set unmodified — `fixtures/copilot-compat-*.json`
+- [x] Which events fired, and which did not, is written down — `fixtures/README.md:49-67` (now partly stale, finding #6)
+- [x] The captured payload is recognised as Copilot, and its session id read — `hooks/lib/host.js:50-56`; `record.js:158-159`
+- [x] A test fails if recognition of that shape regresses — `aidd-telemetry-journal.test.js`
+- [x] An unrecognised payload is distinguishable from no payload at all — `journal.js:39-56`, `record.js:271-297`; the reader's own type check is missing (finding #11)
+
+### telemetry-v1-close — Phase 2 — Each way the chain breaks is named as itself
+
+- [x] The skill runs its own script, and reaches neither the CLI nor another skill — `telemetry-check.js:13-22` requires only `./lib/*`; proved by running the whole skill tree with no `hooks/` beside it
+- [x] Every line is one claim, and carries what it was read from — ran it: four claims plus the uncovered lines, each with its source
+- [x] Each of the four failures is induced and named as itself — `telemetry-check.test.js`, 68 tests pass
+- [x] An uncovered tool is named with its reason and never counted as healthy — ran it against a live fixture project: `not covered: cursor --`, `not covered: copilot --`
+- [x] With measurement off, the run stops and says so first — `telemetry-check.js:99-105`
+- [x] A hook never observed firing reads as such, not as a broken install — `diagnose.js:110-132`, three-way plus the trust branch; the trust branch's gate is finding #4
+
+### telemetry-v1-close — Phase 3 — The layer has met a hundred sessions
+
+- [x] A hundred sessions over a year of day files answer — `telemetry-cost-report.test.js`
+- [x] The breakdown reconciles to the total exactly — `assert.equal`, no tolerance
+- [x] The timings are written down — `2026_08_21_telemetry-v1-close/measurements.md:101-117`
+- [x] The cap is justified by a timing, in one line — `file-writes.js:63-68`
+- [x] Reaching the cap says what was dropped — `file-writes.js:178-183`; ran a run file carrying `scan_truncated` through the report and the diagnostic, both ignore it. The watermark side effect is unfixed (finding #9)
+
+### telemetry-v1-close — Phase 4 — A real multi-step flow reconciles
+
+- [x] A real multi-step flow reports one row per step — `2026_08_21_telemetry-v1-close/measurements.md`
+- [x] The breakdown reconciles to the total — same
+- [x] Work outside any step reads unattributed — same
+- [x] The diagnostic and the report agree on which sessions exist — same
+- [x] Every epic boundary is stated as met or excluded, against real coverage — same
+
+### telemetry-every-tool — Phase 1 — A script runs from the tree an install actually carries
+
+- [x] Every skill script starts from a tree holding only `skills/` — ran `plugin-install-shape.test.js` on an untouched copy: 8/8 pass, three scripts discovered per shape
+- [x] A script reaching outside it fails, naming the file — mutation: prepended `require("../../../hooks/lib/record.js")` to `02-check/scripts/lib/diagnose.js` in a copy → both shapes failed with "could not load … Cannot find module"
+- [x] A script added later is covered without editing the test — mutation: added `skills/03-new/scripts/newthing.js` reaching across the boundary → the run went 8 tests to 10, and the new script failed on the flat shape
+- [x] The same holds for the shape a native install delivers — the phase file admits the shape is reconstructed; verified it against a real `aidd plugin install --tool claude`, which produced exactly `.claude/plugins/aidd-telemetry/{skills,hooks}`. Nothing pins the reconstruction (finding #15's class)
+
+### telemetry-every-tool — Phase 2 — Codex says when it is holding a hook back
+
+- [x] Installing hooks for a gated tool names what still has to happen — ran it: `Plugin "aidd-telemetry" (codex): Codex will not run this plugin's hooks until each one is trusted — …`
+- [x] A tool with no gate is told nothing about one — same run, `--tool claude` printed only `Plugin added successfully.`
+- [ ] An untrusted hook reads as untrusted, never as never fired — the branch exists (`diagnose.js:33-42`, `hook-trust.js`) and is unit-tested, but `telemetry-check.js:120` only reads trust when `CODEX_THREAD_ID` is set, and `session-anchor.js:8-15` records that variable as measured only under `--dangerously-bypass-hook-trust` → `fix`
+- [ ] Both answers come from a Codex session that was actually run — the plan's `measurements.md` has no Phase 2 section; nothing in the tree records a Codex session run untrusted and then trusted → `fix`
+
+### telemetry-every-tool — Phase 3 — A Copilot session names the step it is in
+
+- [x] A real Copilot skill call is held as a fixture, key set unmodified — `fixtures/copilot-compat-post-tool-use-skill.json`; provenance at `fixtures/README.md:69-79`
+- [x] A Copilot session running a skill opens a step naming it — `step-starts.js:85-104`, driven by the captured payload
+- [x] Both payload shapes open a step, or the unclaimed one is named as such — both readers wired through `skillNameFromAnyArgument`, both fixtures present
+- [x] A tool call that is not a skill opens nothing — `copilot-compat-post-tool-use.json` (a Bash call) covers it
+- [x] The limits document says what Copilot supplies, with the capture behind it — `docs/telemetry-limits.md:60-80`
+
+### telemetry-every-tool — Phase 4 — Cursor either runs a plugin hook, or is known not to
+
+- [x] What fires under Cursor is recorded per scope, interactive and headless — `measurements.md:5-232`, `:664-810`
+- [x] What registers a plugin for Cursor is established, or stated as unknown — `measurements.md:95-140`
+- [x] A mapping changes only where a probe showed which event marks the end — `measurements.md:753-800` ran both modes before `CURSOR_EVENT_MAP` changed
+- [x] Cursor's entry in the limits document cites the session behind it — `docs/telemetry-limits.md:33-43`; its last sentence overstates (finding #7)
+
+### telemetry-every-tool — Phase 5 — OpenCode's own session id reaches the journal
+
+- [x] Whether an OpenCode session sees its own id is settled by running one — `measurements.md:427-484`
+- [x] If it does, a sweep reaches that session without it being named by hand — `measurements.md:448-468` and `:1095-1137`; `readers.js:341-349` flipped, pinned against the CLI at `registry-conformance.unit.test.ts:290-308`
+- [x] If it does not, the declared reason cites the probe — `not-applicable`, it does
+
+### telemetry-every-tool — Phase 6 — Cursor's hooks install where Cursor reads them
+
+- [x] Installing for Cursor writes hooks into the file Cursor reads — ran it: `.cursor/hooks.json` carries `sessionStart`/`stop`/`sessionEnd`/`postToolUse`, each `node ./.cursor/hooks/aidd-telemetry/journal.js …`
+- [x] Nothing is left in the plugin directory Cursor does not read — same run: all 30 files under `~/.cursor/plugins/local/aidd-telemetry/` are `skills/**`, no `hooks.json`
+- [x] An interactive Cursor session journals a start and a turn boundary — `measurements.md:776-786`
+- [x] A headless one does too, from whichever event fires there — `measurements.md:762-771`
+- [x] Cursor's repository root resolves from `workspace_roots` — `hooks/lib/repo.js:52`
+- [x] Every other host's resolution is unchanged — `CWD_READER_BY_HOST` keeps `payload.cwd` for the other four and adds OpenCode on the same key
+
+### telemetry-every-tool — Phase 7 — What was proven by hand is what an install delivers
+
+- [ ] An OpenCode install delivers a module its loader runs — true of `aidd plugin install` (ran it: `.opencode/plugin/{opencode-plugin.js,journal.js,lib/*}`), false of `aidd setup --ai opencode --plugins aidd-telemetry` and of `aidd framework build --target opencode --flat`, which deliver nothing (finding #1) → `fix`
+- [x] A session after that install journals — `measurements.md:876-965`; `opencode-plugin.test.js` drives the installed layout end to end
+- [ ] No message claims hooks cannot work there — reproduced: `aidd setup --ai opencode --plugins aidd-telemetry` prints `Warning: Skipping hooks/ in plugin 'aidd-telemetry' (hooks not supported for this target).` → `fix`
+- [x] A marketplace install puts Cursor's hooks where a local one does — `install-plugin-cursor-marketplace-hooks.integration.test.ts:200-213` drives both real translators
+- [x] A test fails when the two routes disagree — same test, pinned to `cursor.ts`'s own `hooksDestination` at `:193-201`, so a shared regression fails too
+- [x] Removing a plugin leaves nothing it merged or copied — ran install then remove for Cursor and OpenCode: `.cursor/hooks/aidd-telemetry/` gone, `.cursor/hooks.json` back to `{"version":1,"hooks":{}}`, `~/.cursor/plugins/local/aidd-telemetry/` gone, `.opencode/` gone with `opencode.json` untouched
+- [x] Installing twice leaves one copy — `cursor-hooks-project-merge.unit.test.ts:22-35`; live, the second install is refused outright
+
+## Findings
+
+| Sev | Kind | Phase | Location | Issue | Fix |
+| --- | ---- | ----- | -------- | ----- | --- |
+| 🔴 | functional | et p7 | `cli/src/application/use-cases/framework/strategies/tool-contracts.ts:785` | **The documented onboarding path for OpenCode ships no journal and says hooks are unsupported.** `buildOpencodeFlatContract` still declares `hooks: { supported: false }, // opencode has no HasHooks capability` — a comment that `opencode.ts:163-165` now contradicts. Reproduced twice on a clean temp project against `cli/dist/cli.js`: `aidd setup --source local --path --ai opencode --plugins aidd-telemetry --yes` prints `Warning: Skipping hooks/ in plugin 'aidd-telemetry' (hooks not supported for this target).` and creates no `.opencode/plugin/` at all; `aidd framework build --target opencode --flat` does the same (31 files, all `skills/`). Only `aidd plugin install --tool opencode` delivers the module. So the tool the plan just proved can journal does not journal on the route `deps.ts:364` wires into `setup`, while `docs/telemetry-limits.md:136-139` states the journal now covers all five hosts. This fails phase 7 task 1 criteria 1 and 3 verbatim, and it is the milestone's own failure shape: an install that reports success and measures nothing. | Give the flat build contract a hooks artifact driven by `opencode.capabilities.plugins.flatHooksDir` (the same declaration `translateFlat` reads), so `writeHooks` copies `hooks/**` minus `hooks.json` into `.opencode/plugin/`. Delete the false comment. Add a test that fails when a tool declaring `acceptsHooks: true` gets `supported: false` from its build contract — the two-declaration-sites check that already exists for Cursor's destination. |
+| 🟡 | rot | phi p2 / et p7 | `cli/src/domain/capabilities/plugins-capability.ts:100-104,110-115,128-131`; `cli/src/domain/models/plugin-content-translator.ts:286-297` | **A whole "a tool that runs no hooks says why" path with no production caller.** All five registered tools declare `acceptsHooks: true` (`grep acceptsHooks cli/src/domain/tools/ai/*.ts`) and no tool uses `mode: "unsupported"` anywhere. The diff admits it: `plugin-add-skip-warn.integration.test.ts:4-11` says "no live fixture currently exercises `collectHooksSkips`'s non-empty branch". So `hooksUnsupportedReason` on three param shapes, the `false` arms of `HooksSupport`/`FlatHooksSupport`, `UnsupportedPluginsParams`, and the hooks arm of `PluginTranslationSkip` are reachable only from test doubles. `plugin-content-translator.ts:289`'s `|| hooksUnsupportedReason === null` disjunct is dead outright: the constructor makes that field non-null exactly when `acceptsHooks` is false, so the first operand always short-circuits first. Phase 2 of plugin-hooks-install built this; phase 7 of every-tool removed its last consumer; nobody reconciled the two. | Decide one way. Either delete the `false` arms and the skip path and let a future tool re-add them with a caller, or keep them and say at the declaration that no shipped tool takes them today. Drop the dead disjunct either way. |
+| 🟡 | rot | phi p3 | `docs/ARCHITECTURE.md:45-54` | The hook-support table added by this same diff is already false in two rows. Cursor reads "declared … Two headless probes fired no plugin hook at all", while phase 4 ran three probes including an interactive one and phase 6 has Cursor journalling in both modes. OpenCode reads "Runs bundled hooks: **no** … a declarative `hooks.json` means nothing to it", while `opencode.ts:163-165` declares `acceptsHooks: true` and an install delivers a module. The closing line "A tool that runs no hook says why" describes a state no tool is in. This is the first table a reader hits from the repo root. | Rewrite the Cursor and OpenCode rows from `measurements.md` phases 4-7, and replace the closing line with what is now true: every tool runs a delivered hook, and one of them gates it behind a trust grant. |
+| 🟡 | functional | et p2 | `plugins/aidd-telemetry/skills/02-check/scripts/telemetry-check.js:120`; `skills/02-check/scripts/lib/session-anchor.js:8-15` | The Codex trust diagnosis — the whole point of phase 2 task 2 — only runs when `process.env.CODEX_THREAD_ID` is set, and the plugin's own comment says that variable was "measured in the environment of a shell command Codex ran under three bypass flags (… `--dangerously-bypass-hook-trust` …), not confirmed for a normal, trust-gated interactive session". The untrusted session is the only case the feature exists for, and its precondition is unmeasured there. If the variable is absent, the diagnostic falls back to `the hook has never been observed firing` — the exact wrong answer phase 2 was written to remove. Nothing in either `measurements.md` records a Codex session run with the hook untrusted. | Run one `codex exec` with the hook untrusted, `env | grep CODEX_THREAD_ID` inside it, and paste both the environment and the diagnostic's line, the way phases 4-7 paste theirs. If the variable is absent there, read the trust state from the presence of a Codex-shaped run file or a Codex plugin directory instead of from the anchor. |
+| 🟡 | rot | et p6/p7 | `cli/src/application/use-cases/plugin/translator/project-hooks-materializer.ts:59`; `cli/src/application/use-cases/plugin/plugin-remove-use-case.ts:79`; `cli/src/domain/formats/cursor-hooks-project-merge.ts:15` | The declaration is tool-neutral and the implementation is not. `hooksDestination: "project"` reads as "the project's own hooks file", but `ProjectHooksMaterializer.mergeProjectHooksJson` hardcodes `join(projectRoot, ".cursor", "hooks.json")`, `PluginRemoveUseCase.removeProjectHooks` hardcodes the same string a second time, and `cursor-hooks-project-merge.ts` hardcodes `.cursor/hooks/`. A second tool that sets `"project"` — which the field's own doc comment invites — would silently have its hooks merged into Cursor's file and converted by `mergeCursorFlatHooks` into Cursor's event vocabulary. Three copies of one path, and a name that promises more than the code does. | Either name the destination on the capability (a `projectHooksPath` plus the merge function to use) so the three sites read it, or rename the field to say Cursor, per CLAUDE.md's "name by intention" and "no speculative generality". |
+| 🟡 | rot | tv1c p1 / et p6 | `scripts/__tests__/fixtures/README.md:106-108`, `:120-121` | Stale in two places this diff invalidated. "All **four** hosts are declared in `lib/host.js`'s `DECLARED_HOSTS`" — there are five since `host.js:18` added `opencode`. And "**Cursor** fires no `Stop`-equivalent hook when run headless (`sessionEnd` arrives instead, and **is not mapped to `turn-end`** — see issue #680)" is now the opposite of the truth: `flat-hooks-merge.ts:41` fans `Stop` to `["stop", "sessionEnd"]`, and I read the mapping back out of a live install's `.cursor/hooks.json`. | Update both, and add OpenCode's entry to the host list saying it has no captured fixture because its payload is self-built by `hooks/opencode-plugin.js`. |
+| 🟡 | rot | et p4/p6 | `docs/telemetry-limits.md:41-43` vs `cli/src/domain/formats/flat-hooks-merge.ts:32-40` | The doc states "Both are subscribed, so each mode records exactly one turn boundary" as a fact. The code comment three files away states the opposite premise — "A run file already tolerates more than one `turn_end` line (two real `stop` firings, one interactive session, Phase 4 addendum)" — and `measurements.md:284-289` shows that run file, two `turn_end` lines from one session. Phase 6 measured one boundary per mode on *clean* exits only, and says so ("in every session observed to date"); the doc drops the qualifier. A consumer counting turns from the doc's sentence would be wrong on an aborted session. | Say what was measured: a clean session in either mode records one boundary, and an interrupted one can record more, which readers tolerate. |
+| 🟡 | fit | et p3 | `docs/telemetry-limits.md:88` | "All five tools now leave a run journal, **each proven by a session that was actually run**." Cursor, OpenCode, Codex and Claude Code each have a pasted run file in a `measurements.md`. Copilot has none: `2026_08_21_telemetry-v1-close/measurements.md:379` says "Copilot and Cursor were not run here", the every-tool `measurements.md` has no Phase 1-3 section at all, and what the Copilot captures establish is that its hook fires and what payload arrives — the journal write is proven by replaying those fixtures. That is a weaker chain than the sentence claims, in the document whose whole premise is that limits are established by probing. | Either paste a run file from the Copilot session that produced the fixtures, or narrow the sentence to what the capture supports: Copilot's hook fires and its payload is recognised, and the journal write from it is covered by replay. |
+| 🟡 | code | tv1c p3 | `plugins/aidd-telemetry/hooks/lib/file-writes.js:168-183` | Unchanged, and still untested. `since = lastWriteMs(filePath)` is the run file's mtime, and appending the `scan_truncated` marker moves it. A turn that walked 2000 entries, found nothing, and gave up now pushes the next turn's window past writes a later, smaller walk would have recovered. `aidd-telemetry-file-writes.test.js:130-134` asserts the line appears and never what the next turn then sees. | Write the marker before the walk, or restore the mtime after it, and add a test where a file written during a truncated turn is still observed by the next one. |
+| 🟡 | code | phi p2/p3 | `cli/tests/domain/models/installed-hook-resolves.unit.test.ts:68-75`, `:126` | The file's premise — "reads the command back out of what was installed" — is no longer true for Cursor. `installed()` calls `PluginContentTranslator` directly with the full distribution, while the real Cursor route passes `withoutHooks(dist)` (`mode-b-flat-materialization-translator.ts:97`), so every Cursor assertion here is about a plugin-scoped `hooks.json` no install produces. Separately, `:126` still recomputes the build side as `rewritePluginRootToken(HOOKS_JSON, token)` rather than invoking `MarketplaceBuildStrategy`, which is what phase 3's "the same hook command comes out of either route" asks for and what the prior review already named. | Drop Cursor from `HOOK_HOSTS`/`BUILT_BY` here and let `install-plugin-cursor-marketplace-hooks.integration.test.ts` own it, or drive Cursor through its real route. Drive the build side through the strategy for the remaining tools. |
+| 🟢 | code | tv1c p1 | `plugins/aidd-telemetry/skills/02-check/scripts/telemetry-check.js:82-95` | Unchanged from the prior review, and reproduced. `readUnrecognisedPayload` never checks the line's `type` or that `at` is a string, unlike its sibling `readJournalFile` (`lib/journal.js:31-40`). Wrote `{"type":"session_start"}` into `aidd_docs/runs/_unrecognised.jsonl` in a temp project and ran the script: `hook fired FAIL a payload arrived and matched no known host at undefined`. Two defects in one line — a wrong claim, and `undefined` printed to the user. | Require `type === "unrecognised_payload"` and a string `at`; otherwise return null and let the generic fault answer. |
+| 🟢 | conform | phi p2 | `cli/src/domain/tools/build-contract.ts:89` | `pluginRootToken`'s doc comment still lists `"${COPILOT_PLUGIN_ROOT}"` among its examples. No tool declares it — `copilot.ts:326` declares `${PLUGIN_ROOT}` — which is exactly what phase 2 task 3 asked to correct ("The rewrite's own documentation names Copilot's token as `${COPILOT_PLUGIN_ROOT}`; the declaration is what runs. Correct the prose."). The sibling `plugin-root-token-rewrite.ts` was corrected in this diff; this one was missed. | Drop the example list, or reduce it to the three constants the module actually exports. |
+| 🟢 | rot | et p7 | `plugins/aidd-telemetry/hooks/opencode-plugin.js` (delivery) | OpenCode's ESM runtime module is delivered into every tool's hook directory. Verified on live installs: `.cursor/hooks/aidd-telemetry/opencode-plugin.js`, `.claude/plugins/aidd-telemetry/hooks/opencode-plugin.js`, `.codex/plugins/aidd-telemetry/hooks/opencode-plugin.js`. Four of five tools get a file only the fifth can load, sitting in the directory they scan for hook scripts. | Either move it out of `hooks/` into a directory only the flat route reads, or filter it in `translateNative` the way `hooks.json` is filtered in `flatHooksFiles`. |
+| 🟢 | error-handling | et p5/p7 | `plugins/aidd-telemetry/hooks/opencode-plugin.js:30-35` | `spawnSync("node", …)` — deliberately not `process.execPath`, since OpenCode ships as its own binary — and the result is never inspected. A machine with OpenCode but no `node` on `PATH` journals nothing, forever, silently. Every other host runs `journal.js` under a Node that exists by construction; this is the one delivery route where it may not, and it is also the route whose earlier `file://` bug the phase-7 comment says was invisible for exactly this reason. | Check `result.error`/`result.status` once and record the failure where the diagnostic can see it, or state at the call site why a missing `node` is acceptable to lose. |
+| 🟢 | rot | tv1c p2 | `scripts/__tests__/telemetry-check.test.js:484-493` | Every duplicated declaration is guarded, and I proved each guard fires: mutating `unrecognised.js`'s constant, `switch.js`'s predicate, `repo.js`'s git argv and `journal.js`'s bytes in a copy failed 6 tests. But the byte-parity block is still the hardcoded three-name allowlist the prior review flagged, so a fourth shared file added later announces nothing — `render.js` already exists in both `lib/` directories with different contents and no statement anywhere that the divergence is intended. | Enumerate both `lib/` directories and fail on any shared filename that is neither byte-identical nor on an explicit "deliberately different" list. |
+| 🟢 | conform | - | `aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/plan.md:3` and `phase-{2..7}.md:2`; `aidd_docs/memory/testing.md:19` | Two bookkeeping carry-overs. The every-tool plan and six of its seven phase files still carry `status: pending` while their work is in this diff — the other two plans are all `done`. And the committed project memory still tells every contributor to "Run biome through `rtk proxy`", a personal token-proxy this repo neither declares nor installs. | Flip the statuses. State the `rtk` line as an environment caveat, not as the project's command. |
+
+## Verification
+
+| Metric | Value |
+| ------------- | ------------------------------------------------- |
+| Verified | 84% (63/75) |
+| Files checked | `plugins/aidd-telemetry/hooks/{journal.js,opencode-plugin.js}`, `hooks/lib/{file-writes,host,record,repo,step-starts}.js`, `plugins/aidd-telemetry/skills/{00-init,01-cost,02-check}/**`, `plugins/aidd-telemetry/{CATALOG.md,README.md}`, `scripts/__tests__/{plugin-install-shape,telemetry-check,opencode-plugin,aidd-telemetry-file-writes,aidd-telemetry-journal,aidd-telemetry-cost-skill,telemetry-cost-report,telemetry-cost-readers}.test.js`, `scripts/__tests__/fixtures/README.md`, `cli/src/domain/capabilities/plugins-capability.ts`, `cli/src/domain/formats/{flat-hooks-merge,plugin-root-token-rewrite,cursor-hooks-project-merge}.ts`, `cli/src/domain/models/{plugin-content-translator,plugin-install-notice,plugin-translation-skip}.ts`, `cli/src/domain/tools/{build-contract.ts,ai/*.ts}`, `cli/src/application/use-cases/plugin/{plugin-add-use-case,plugin-remove-use-case}.ts`, `cli/src/application/use-cases/plugin/translator/{project-hooks-materializer,built-tree-materialization-translator,mode-b-flat-materialization-translator}.ts`, `cli/src/application/use-cases/framework/strategies/{tool-contracts,flat-build-strategy}.ts`, `cli/tests/domain/**`, `cli/tests/application/use-cases/plugin/**`, `cli/tests/helpers/telemetry-cost-readers.ts`, `docs/{ARCHITECTURE,CATALOG,telemetry-limits}.md`, `aidd_docs/memory/testing.md`, all three plans and both `measurements.md` |
+| Unchecked | phi p1 "Codex's and Cursor's declared tokens are ones a running hook resolved" — not-applicable; phi p2 "A tool that runs no hooks receives none, and states why" — not-applicable (no such tool exists any more); phi p2 "The same plugin, built and installed, yields the same hook command" — fix; phi p2 "No document names a token that differs from the one the tool declares" — fix; phi p3 "Both routes deliver hooks exactly when the tool runs them" — fix; phi p3 "The same hook command comes out of either route" — fix; phi p3 "A component missing from one route fails, naming it" — fix; phi p3 "Every tool's hook support is documented, including those with none" — fix; et p2 "An untrusted hook reads as untrusted, never as never fired" — fix; et p2 "Both answers come from a Codex session that was actually run" — fix; et p7 "An OpenCode install delivers a module its loader runs" — fix; et p7 "No message claims hooks cannot work there" — fix |
+| Unplanned | `scripts/test-changed.mjs` + `package.json:31` and the `aidd_docs/memory/testing.md` rewrite trace to no criterion in any of the three plans; the every-tool plan carries no `measurements.md` section for phases 1-3, so those three phases have no evidence record beside the fixtures; `plugin-add-opencode-hooks-skip.integration.test.ts` is deleted with its replacement asserting the opposite outcome, which is correct but traces to phase 7 task 1 rather than to the phase-1 comment it still carries |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/spec.md b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/spec.md
new file mode 100644
index 000000000..e4990172b
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/spec.md
@@ -0,0 +1,49 @@
+---
+status: draft
+---
+
+# Spec: measurement that works on every tool, proven on each
+
+## The ask
+
+Telemetry that works for all five tools, established by running them, not by reading their source.
+
+## Why the answer cannot be "the same thing five times"
+
+The tools do not offer the same surfaces, and a contract that pretends they do would be met by declaring success. What a consumer needs is the opposite: one output shape, and per tool an honest statement of which parts of it that tool can fill and which it cannot, each backed by a capture.
+
+Two independent capabilities decide what a tool can supply, and they fail separately:
+
+- **A tool can journal.** Its hooks run, they see a session identifier, and a step boundary can be recorded. This is what ties consumption to the work that caused it.
+- **A tool can be read.** Something it writes carries token counts that can be joined to that session. This is what turns work into a figure.
+
+A tool can have either, both, or neither. Claude Code has both. OpenCode has the second and not the first. Cursor has neither.
+
+## What "works" means, per tool, testably
+
+A tool is done when all four hold:
+
+1. A real session on that tool leaves a run journal naming the session, its tool, and at least one step boundary.
+2. Either a figure is produced for that session and reconciles to its breakdown exactly, or the tool declares precisely why no figure exists — and that declaration is backed by a capture, not by an argument.
+3. The diagnostic, run inside a session on that tool, answers every claim without reading `--` for a reason that is "nobody measured".
+4. Nothing about the tool is asserted anywhere in the repository that a capture does not support.
+
+## State today, measured
+
+| Tool | Journals | Readable into a figure | What stands in the way |
+| --- | --- | --- | --- |
+| Claude Code | yes, proven on a live three-skill chain | yes, reconciles exactly | nothing |
+| Codex | yes, proven on a live session | yes | it silently declines to run a hook it was never asked to trust |
+| Copilot | its payload is recognised, as of a real capture | no per-request input figure exists in its own files | a skill call opens no step, so every record reads unattributed |
+| Cursor | no plugin-scope hook was observed firing at all | it writes no token count in any file | both, and the first blocks the second from mattering |
+| OpenCode | nothing establishes that anything sees its own session id | yes, but the figures cannot be joined to a session | the join |
+
+## Done when
+
+- Each of the five tools satisfies the four conditions above, or its failure to is a measured statement in the repository rather than a gap.
+- Every claim about a tool in code, tests or documentation cites the capture behind it.
+- One branch carries the work, and every issue it closes says what closed it.
+
+## Explicitly not this
+
+Aggregation across people or teams, the upload path, and the commit trailer. They belong to the milestone after and none of them is blocked by this.
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/phase-1.md
new file mode 100644
index 000000000..2c8cd1d3a
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/phase-1.md
@@ -0,0 +1,72 @@
+---
+status: pending
+---
+
+# Instruction: A journal is never offered to a commit
+
+## Architecture projection
+
+```txt
+.
+├── cli/src/application/use-cases/shared/post-install-pipeline-use-case.ts ✏️ the journal joins the cache
+└── plugins/aidd-telemetry/skills/00-init/ ✏️ turning it on ignores it, CLI or not
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[measurement is turned on] --> B{is the journal ignored?}
+ B -->|no| C[it is ignored now]
+ B -->|yes| D[nothing to do]
+ C --> E[a session writes, and git does not offer it]
+ E --> F{already in history?}
+ F -->|yes| G[said plainly, with what it contains]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ a fresh project, measurement turned on, one session journalled: 5: system
+ section Happy path
+ git status offers nothing from aidd_docs/runs: 5: cli
+ section Edge case - already ignored
+ the entry is not written twice: 1: cli
+ section Edge case - already committed
+ the person is told, and nothing is rewritten for them: 1: cli
+ section Edge case - no CLI
+ turning it on through the skill alone still ignores it: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Ignore it wherever measurement gets turned on
+
+> `aidd setup` writes one entry, `.aidd/cache/`. The plugin writes none at all. So the only project where the journal is ignored is the one where somebody typed it by hand — this one.
+
+1. Turning measurement on adds the journal to the project's `.gitignore`, through the CLI and through the plugin's own switch alike. The plugin cannot call the CLI, so it does its own — the same rule the rest of the plugin follows.
+2. The entry is not written twice, and an existing one is left as it is.
+3. It covers the journal and nothing else. A directory ignored more widely than it needs is how a file someone wanted disappears.
+
+### `2)` Say it when the horse has left
+
+> Someone who turned measurement on before this exists may already have journal files in git history, and no edit to `.gitignore` reaches what is already tracked.
+
+1. Where journal files are already tracked, say so, and say what they contain — who worked on what, for how long, and every file each session wrote.
+2. Do not rewrite history and do not `git rm` anything. What to do about a commit that is already pushed is the person's decision.
+3. Say it once, where they are already looking — at the moment measurement is turned on — not on every run afterwards.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ---------------------------------------------------------------------- |
+| 1 | After turning measurement on, `git status` offers nothing from the journal |
+| 1 | The same holds through the plugin's switch with no CLI installed |
+| 1 | An existing entry is not duplicated |
+| 2 | A journal already tracked is named, with what it contains |
+| 2 | Nothing is removed or rewritten on the person's behalf |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/phase-2.md
new file mode 100644
index 000000000..85887dd6e
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/phase-2.md
@@ -0,0 +1,66 @@
+---
+status: pending
+---
+
+# Instruction: Where each thing lives is a stated choice
+
+## Architecture projection
+
+```txt
+.
+├── docs/telemetry-limits.md ✏️ where things are written, and why there
+└── plugins/aidd-telemetry/README.md ✏️ the same, for someone holding only the plugin
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[where does this write?] --> B[the journal: in the repository it describes]
+ A --> C[the figures: with the person, across repositories]
+ C --> D{a team wants them shared?}
+ D -->|yes| E[a named choice, not a variable found by reading source]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Happy path
+ a reader finds both locations and the reason for each: 5: docs
+ section Edge case - another location
+ choosing one is documented, with what it costs: 1: docs
+ section Edge case - the reason drifts
+ a test fails when the documented path stops matching the code: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Write the decision down where it is looked for
+
+> Both locations are right and neither is stated. The gap in phase 1 is what happens when a decision is made without being written: the consequence goes undrawn.
+
+1. Say where the journal is written and why it belongs to the repository, and where the figures are written and why they belong to the person.
+2. Say what each contains, since that is what makes the first one worth ignoring: who worked on what, for how long, and every file a session wrote.
+3. Say it for someone holding only the plugin, with no CLI installed — that is a supported way to use this and it has its own README.
+
+### `2)` Turn an environment variable into an offered choice
+
+> `AIDD_USER_CONFIG_DIR` already lets the figures live somewhere else. Undocumented, it is a workaround insiders know rather than a choice a person can make.
+
+1. Name it, say what it is for — a team that wants shared figures, a CI that wants its own per repository — and say what it costs: figures outside the default are not swept together with the rest.
+2. Keep the default. The per-user location is right for one person on one machine, which is nearly everyone.
+3. A test fails when the documented path stops matching what the code writes. A location documented once and moved later is worse than one never written down.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------------------------------------------------------ |
+| 1 | Both locations and both reasons are stated where a reader looks |
+| 1 | What each file contains is stated beside where it lives |
+| 1 | The plugin's own README says it too |
+| 2 | Choosing another location is documented, with its cost |
+| 2 | A test fails when the documented path stops matching the code |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/plan.md b/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/plan.md
new file mode 100644
index 000000000..2064dafa1
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/plan.md
@@ -0,0 +1,38 @@
+---
+objective: "The journal is never offered to a commit, and where each thing lives is a decision a reader can find."
+status: pending
+---
+
+# Plan: where measurement lives
+
+## Overview
+
+| Field | Value |
+| ---------- | ------------------------------------------------------------ |
+| **Goal** | Private files stay private, and the choice behind them is stated |
+| **Source** | [`spec.md`](./spec.md) |
+
+## Phases
+
+| # | Phase | File |
+| --- | ---------------------------------------------- | ---------------------------- |
+| 1 | A journal is never offered to a commit | [`phase-1.md`](./phase-1.md) |
+| 2 | Where each thing lives is a stated choice | [`phase-2.md`](./phase-2.md) |
+
+## Resources
+
+| Source | Verified |
+| --- | --- |
+| `post-install-pipeline-use-case.ts:21` | `aidd setup` writes exactly one gitignore entry, `.aidd/cache/`. Nothing covers `aidd_docs/runs/`. |
+| A grep across the plugin | It never writes a `.gitignore` either, so no route adds one. |
+| This repository's own `.gitignore:47` | `aidd_docs/runs/*` is ignored here, by hand. That is why it was never noticed. |
+| `repo.js:143` and `record.js:172` | `0700` on the directory and `0600` on the files, with an explicit `chmod` because `mkdirSync`'s mode only covers a directory it creates. |
+
+## Decisions
+
+| Decision | Why |
+| --- | --- |
+| The journal stays in the repository, and is ignored there | It records repository-relative paths and task folders; it is a property of that repository. What follows is that it must be ignored, and that is the part nobody drew. |
+| The figures stay per user by default | A session's consumption belongs to the person and the machine, not to whichever checkout they were standing in. A team that wants otherwise has a real case, and it becomes a named choice rather than a variable found by reading source. |
+| Scope is not exposed uniformly | A journal living outside its repository would describe one repository from outside it, and the first question of any reader would be which one. Symmetry here would cost more than it buys. |
+| An existing repository is told, never silently fixed | Someone whose journal is already in git history has a decision to make about that history. Making it for them, quietly, is how a tool loses trust. |
diff --git a/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/spec.md b/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/spec.md
new file mode 100644
index 000000000..b3fcc5324
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_22_where-measurement-lives/spec.md
@@ -0,0 +1,46 @@
+---
+status: draft
+---
+
+# Spec: where measurement lives, decided rather than inherited
+
+## What went wrong
+
+A person turns measurement on. The run journal lands in `/aidd_docs/runs/`, and nothing adds it to their `.gitignore` — `aidd setup` writes one entry, `.aidd/cache/`, and that is all. The files show up in `git status`, a `git add .` takes them, and they reach the remote.
+
+Those files say who worked on what and for how long, and name every file a session wrote. The code already treats them as private: `0700` on the directory, `0600` on the files, and an explicit `chmod` because `mkdirSync`'s mode only applies to a directory it creates. All of that care, and then they are committable.
+
+Nothing fails and nothing warns. That is the failure this whole layer exists to remove, arriving through the door nobody watched.
+
+## Why it happened
+
+Two locations were chosen and never written down as a decision:
+
+- the **run journal** is per repository, because it records repository-relative paths and task folders
+- the **stored figures** are per user, under `AIDD_USER_CONFIG_DIR` or `~/.config/aidd`, because a session's consumption belongs to the person and their machine rather than to whichever checkout they were standing in
+
+Both are right. Neither is stated anywhere a reader would find, so the consequences of the first — it is inside a git repository, therefore it must be ignored — were never drawn.
+
+## The asymmetry, and why it stays
+
+Scope is not a knob to expose uniformly.
+
+The journal **is** a property of a repository. Letting it live elsewhere would create a file describing one repository from outside it, and the first question of anyone reading it would be which one.
+
+The figures are different. The per-user default is right, and a real case exists against it: a team that wants them shared, a CI that wants its own per repository. That choice already exists as `AIDD_USER_CONFIG_DIR` — but an undocumented environment variable is not an offered choice, it is a workaround insiders know.
+
+So the work is to state the decision, ignore what follows from it, and turn the existing override into something a person can find.
+
+## Done when
+
+- A project where measurement was turned on does not offer its run journal to a commit.
+- A repository that already has journal files in its history is told, rather than silently left as it is.
+- Where each thing is written, and why there rather than elsewhere, is stated where someone looks before asking.
+- Choosing another location for the figures is a documented choice with a name, not an environment variable found by reading source.
+- A test fails when turning measurement on leaves the journal committable.
+
+## Not this
+
+Moving anything. Both locations are correct and this changes neither.
+
+Nor the second gap found beside it: nothing here has been run on Windows or Linux, and `~/.config/aidd` is not where a Windows user expects it. That needs machines this work does not have, and claiming it works would be the same sin in a different file.
diff --git a/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-1.md b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-1.md
new file mode 100644
index 000000000..fbc7adcfc
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-1.md
@@ -0,0 +1,81 @@
+---
+status: pending
+---
+
+# Instruction: Any dimension filters as well as it groups
+
+## Architecture projection
+
+```txt
+.
+├── plugins/aidd-telemetry/skills/01-cost/scripts/telemetry-report.js ✏️ filters, plural
+├── plugins/aidd-telemetry/skills/01-cost/scripts/lib/report.js ✏️ narrow, then group
+└── cli/src/…/cost-report.ts ✏️ the same, both sides
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[a question] --> B[keep only what matches: project, task, step, tool, model, day]
+ B --> C[group what is left, along one of the same dimensions]
+ C --> D{did anything match?}
+ D -->|no| E[the filter that emptied it is named]
+ D -->|yes| F[a breakdown that sums to this selection's own total]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ the sink at a hundred sessions, several projects, several tasks: 5: system
+ section Happy path
+ project as filter and step as axis => answers, and sums to that selection: 5: plugin
+ section Edge case - filters compose
+ two filters narrow by and, never by or: 1: plugin
+ section Edge case - nothing matches
+ the filter that emptied it is named, never a total of zero: 1: plugin
+ section Edge case - a dimension as both
+ filtering and grouping on the same one is one row, not an error: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Make every dimension a filter
+
+> Today a report takes a period and `--task`, and picks one axis. A project over a week broken down by step cannot be asked for, and it is the most ordinary question there is.
+
+1. Day, project, task, step, model and tool each work as a filter, alongside the period that already exists.
+2. Filters compose by `and`. Two named narrow to the intersection, and the report says which selection it answered so a figure can be cited without its command.
+3. Filtering and grouping on the same dimension is a legal, boring answer — one row — not an error to guard against.
+
+### `2)` Keep the arithmetic true under any selection
+
+> A breakdown that no longer sums to its own total is how a report starts lying quietly, and a selection makes that easier to miss.
+
+1. Under any combination of filters, every breakdown sums to that selection's own total, exactly, integer for integer.
+2. `session`-shaped records still never sum with `request`-shaped ones. A filter narrows what is counted; it does not change what may be added.
+3. The CLI and the plugin answer identically — the byte-comparison test already holds them to it, and it must keep holding.
+
+### `3)` Say when nothing matched
+
+> A period with no work is a row of zeros because the zero is true. A filter that matches nothing is a different thing entirely, and printing zero for it would be the lie this layer exists to remove.
+
+1. An empty selection names the filter that emptied it, and says what would have matched without it where that is cheap to know.
+2. A filter naming something that never existed — a project nobody worked in — is told apart from one that existed and had no work in the period.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | --------------------------------------------------------------------- |
+| 1 | Each of the six dimensions works as a filter |
+| 1 | Two filters narrow by `and` |
+| 1 | Filtering and grouping on one dimension answers with a single row |
+| 2 | Every breakdown sums to its selection's own total, exactly |
+| 2 | The CLI and the plugin answer identically |
+| 3 | An empty selection names the filter responsible |
+| 3 | An unknown value is told apart from a known one with no work |
diff --git a/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-2.md b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-2.md
new file mode 100644
index 000000000..0c3935cf2
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-2.md
@@ -0,0 +1,81 @@
+---
+status: pending
+---
+
+# Instruction: A person can choose to be named, and unchoose
+
+## Architecture projection
+
+```txt
+.
+└── plugins/aidd-telemetry/skills/00-init/
+ ├── actions/ ✏️ choosing, and taking it back
+ └── scripts/ ✏️ where the choice is kept, and what it holds
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[measurement is on] --> B{did this person choose to be named?}
+ B -->|no, the default| C[records carry no identity at all]
+ B -->|yes| D[records carry an identifier they hold]
+ D --> E{withdrawn?}
+ E -->|yes| F[new records carry none, and what is already written is said]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ a default installation, measurement on, one session: 5: system
+ section Happy path
+ nothing written carries an identity: 5: plugin
+ section Edge case - opting in
+ a session after it carries the identifier, and one before it does not: 1: plugin
+ section Edge case - withdrawing
+ new records carry none, and what is already written is stated: 1: plugin
+ section Edge case - the choice lives with the person
+ a repository or a CI variable cannot make it for them: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Record nothing about anyone, until asked
+
+> This is the claim that has to hold before any of the rest is safe to build, and it must be provable by reading what gets written rather than by reading the setting.
+
+1. A default installation writes no identity, anywhere — not in the journal, not in the stored figures.
+2. Prove it by running a session and reading every line produced, not by asserting the flag is off.
+3. Where an identity is absent, the figure stays complete and reads as belonging to no person — never as missing, never as zero.
+
+### `2)` Let a person choose, and take it back
+
+> Something a person cannot withdraw is not a choice they were offered.
+
+1. Opting in is one action, on their own machine, and says plainly what it will attach to and what it will not.
+2. The choice belongs to the person, not the repository: a checkout, a CI variable or a lead cannot make it for them, and a test says so.
+3. Withdrawing is one action, stops new records carrying it, and says what happens to those already written. Records from before an opt-in stay anonymous — a choice made today does not reach backwards.
+
+### `3)` Keep the identifier and the name apart
+
+> They are different decisions and conflating them is how a system that promised anonymity starts showing names.
+
+1. What joins a person's records across tools and machines is an identifier they hold, stable and not derived from anything that identifies them elsewhere.
+2. A display name is a separate field that exists only once asked for, and its absence is normal rather than incomplete.
+3. Nothing derives one from the other, in either direction.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | ------------------------------------------------------------------- |
+| 1 | A default installation writes no identity, proven from what it wrote |
+| 1 | A figure with no person is complete, not missing |
+| 2 | Opting in and out are one action each, and each says what it changes |
+| 2 | A repository or CI variable cannot choose for a person |
+| 2 | Records from before an opt-in stay anonymous |
+| 3 | The identifier and the display name are separate and independent |
diff --git a/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-3.md b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-3.md
new file mode 100644
index 000000000..fda1807a8
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/phase-3.md
@@ -0,0 +1,76 @@
+---
+status: pending
+---
+
+# Instruction: A report answers who, for those who chose
+
+## Architecture projection
+
+```txt
+.
+├── plugins/aidd-telemetry/skills/01-cost/ ✏️ person, as a filter and as an axis
+└── docs/telemetry-limits.md ✏️ what a report can and cannot say about people
+```
+
+## User Journey
+
+```mermaid
+flowchart TD
+ A[who spent it?] --> B[a row per person who chose to be named]
+ B --> C[one row for everyone who did not]
+ C --> D{combined with another filter?}
+ D -->|yes| E[this person, this project, by step]
+```
+
+## Test Scope
+
+```mermaid
+---
+title: Test scope
+---
+journey
+ section Setup
+ sessions from two people who chose, and one who did not: 5: system
+ section Happy path
+ a row each, plus one for the unnamed, summing to the total: 5: plugin
+ section Edge case - crossed with another dimension
+ one person on one project, broken down by step: 1: plugin
+ section Edge case - nobody chose
+ the axis answers with one row, and says why: 1: plugin
+```
+
+## Tasks to do
+
+### `1)` Person, as a filter and as an axis
+
+> Phase 1 made every dimension do both. This is that work applied to the one dimension that needed a decision first.
+
+1. Person joins the dimensions from phase 1, filtering and grouping like any other.
+2. Everyone who did not choose is one row, named as unattributed to a person, and it sums with the rest into the same total.
+3. Crossing person with any other dimension works because both are filters — one person, one project, by step, without a new mechanism.
+
+### `2)` Prove it on real sessions, across tools
+
+> An identity that joins on one tool proves nothing about the thing it exists for.
+
+1. Sessions from more than one tool, for the same person, join into one row.
+2. A session from a person who did not choose stays out of every named row, on every tool.
+3. Run it, do not assert it: `scripts/verify-chain.mjs` already runs a real session per tool and is the place this belongs.
+
+### `3)` Say what a report can and cannot tell you about people
+
+> This is the part someone will be asked about by their own team, and it should be written before they are.
+
+1. `docs/telemetry-limits.md` says what is recorded, when, on whose choice, and what withdrawing does.
+2. It says what this cannot answer — teams, hierarchies, anyone who did not choose — and that those are absences by design rather than gaps.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | --------------------------------------------------------------------- |
+| 1 | Person filters and groups like every other dimension |
+| 1 | Everyone unnamed is one row that sums with the rest |
+| 1 | Person crosses with another dimension without a new mechanism |
+| 2 | One person's sessions from two tools join into one row |
+| 2 | Someone who did not choose appears in no named row, on any tool |
+| 3 | The limits document says what is recorded and what withdrawing does |
diff --git a/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/plan.md b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/plan.md
new file mode 100644
index 000000000..a4a5c5976
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/plan.md
@@ -0,0 +1,43 @@
+---
+objective: "A report answers who spent it, for people who chose to be named, and any dimension filters as well as it groups."
+status: pending
+---
+
+# Plan: who spent it, and every way to ask
+
+## Overview
+
+| Field | Value |
+| ---------- | -------------------------------------------------------- |
+| **Goal** | Identity by consent, and filters that compose |
+| **Source** | [`spec.md`](./spec.md), issues #652 #660 #661 #656 |
+
+## Phases
+
+| # | Phase | File |
+| --- | ---------------------------------------------- | ---------------------------- |
+| 1 | Any dimension filters as well as it groups | [`phase-1.md`](./phase-1.md) |
+| 2 | A person can choose to be named, and unchoose | [`phase-2.md`](./phase-2.md) |
+| 3 | A report answers who, for those who chose | [`phase-3.md`](./phase-3.md) |
+
+Filters first, on purpose. They are worth having without identity, they are provable on data that already exists, and building identity into a report that can only group one way would produce a feature nobody can ask a real question of.
+
+## Resources
+
+| Source | Verified |
+| --- | --- |
+| `telemetry-report.js` | Filters today are a period and `--task`; `--axis` picks one grouping. Nothing composes. |
+| The stored record | Carries tool, model, moment, turn id, step, task, and project with the field that identified it. No identity of any kind. |
+| The sink at a hundred sessions over a year | Answers in under 80ms and every breakdown reconciles exactly — the volume a composed filter has to stay usable at. |
+| `docs/telemetry-limits.md` | Already states the rule this extends: a figure the layer cannot produce is named as missing, never printed as `0`. |
+
+## Decisions
+
+| Decision | Why |
+| --- | --- |
+| An identity is never a default | The counterpart of the rule this layer already lives by. A figure with nobody's name is complete; one with a name nobody agreed to give is worse than no figure at all. |
+| The person who chooses is the person named | Consent given by a repository, a lead or a CI variable is not consent. It is a choice made on someone's own machine, that they can withdraw. |
+| The joining identifier and the display name are separate fields | They are separate decisions. Joining a person's records across their tools needs an identifier they hold; showing a name is a further thing that exists only once asked for. |
+| A choice made today does not reach backwards | Records written before an opt-in stay anonymous. Retroactive naming would mean the choice was never real. |
+| Filters compose by `and`, and never grow a query language | The moment it needs parentheses it has stopped being a report. Every dimension as filter and as axis covers the questions people actually ask; anything past that is a database. |
+| An empty result names the filter that emptied it | Same rule as everywhere here, one dimension further: silence read as a zero is the failure this layer exists to remove. |
diff --git a/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/spec.md b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/spec.md
new file mode 100644
index 000000000..964a98ba8
--- /dev/null
+++ b/aidd_docs/tasks/2026_08/2026_08_23_who-spent-it/spec.md
@@ -0,0 +1,63 @@
+---
+status: draft
+---
+
+# Spec: measurement that can name a person, and does not by default
+
+## What is missing
+
+A report answers by period, by day, by project, by step, by model, by tool and by ticket. It cannot answer *who*, and nothing it stores could be made to — no record carries an identity of any kind, deliberately.
+
+Teams ask two different questions and only one of them is about individuals:
+
+- **How much did this team spend, and on what?** Needs figures aggregated across people, and no name at all.
+- **Is someone stuck, or carrying too much?** Needs a name, and needs the person to have agreed to it.
+
+Building the second by accident while building the first is the failure to avoid.
+
+## Why this is not a grouping
+
+`by_project` was a field carried one hop further. This is not that. Adding an identity means deciding what may be recorded about a person, where it goes, who can read it, and how they take it back. That decision is the work; the code is the small half.
+
+The layer has one rule that already answers most of it: **an unknown is never a zero**. Its counterpart here is that an identity is never a default. A figure with nobody's name on it is complete. A figure with the wrong name on it is worse than no figure, and a figure with a name nobody agreed to give is worse still.
+
+## What is decided here
+
+**Anonymous by default.** Measurement records no identity unless someone turned that on for themselves. A team that never touches this setting still gets every figure it has today, aggregated, forever.
+
+**Named on explicit opt-in, per person, revocable.** The person choosing is the person named. Not their lead, not the repository, not a CI variable — a choice made on their own machine, that they can withdraw, and that says plainly what it will attach their name to.
+
+**A stable identity that is not a name.** What joins records across a person's tools and machines is an identifier they hold; a display name is a separate, later thing that only exists once they have asked for it. The two are different fields because they are different decisions.
+
+**Withdrawal is real.** Turning it off stops new records carrying it and says what happens to the ones already written. Something a person cannot take back is not a choice they were offered.
+
+## Done when
+
+- A report can answer per person, and does so only for people who chose it.
+- A default installation records no identity anywhere, proven by reading what it writes rather than by reading the setting.
+- Opting in, and out, is one action each, and each says what it changes.
+- Records written before an opt-in stay anonymous — a choice made today does not reach backwards.
+- Where an identity is absent, the figure is still complete and reads as unattributed to a person, never as missing.
+- Every claim above is proven by a real session, on more than one tool.
+- Any dimension works as a filter and as an axis, and combining filters narrows by `and`.
+- A filter matching nothing names itself, rather than reporting a total of zero.
+- A breakdown under any combination of filters still sums to that combination's own total, exactly.
+
+## The second half: a filter is not an axis
+
+Naming a person is worth little if the only question you can ask is "everything, by person". Today a report takes a period, one optional task, and **one** axis. You cannot ask for a project over a week broken down by step, and once identity exists you will immediately want one person on one project.
+
+An axis says how to *group*. A filter says what to *keep*. They are the same set of dimensions — day, project, step, model, tool, task, and now person — and every one of them should work as either.
+
+What that buys, in the questions people actually ask:
+
+- *what did this repository cost last month, by step* — project as filter, step as axis
+- *what did I spend on that ticket* — person and task as filters, one total
+- *which day did this project spike* — project as filter, day as axis
+- *who worked on this ticket* — task as filter, person as axis
+
+Two things this must not become. Filters compose by **and**, never by a query language — the moment it needs parentheses it has outgrown a report and become a database. And a filter that matches nothing says so, naming the filter that emptied it, rather than printing a total of zero: the same rule as everywhere else here, one dimension further along.
+
+## Not this
+
+Teams, organisations, or any hierarchy above a person. Those need a source of truth about who belongs where, which this layer does not have and should not invent. Aggregating what people chose to share is in scope; deciding who they are to each other is not.
diff --git a/cli/.claude/rules/00-architecture/0-error-handling.md b/cli/.claude/rules/00-architecture/0-error-handling.md
index 459f3c993..233ced648 100644
--- a/cli/.claude/rules/00-architecture/0-error-handling.md
+++ b/cli/.claude/rules/00-architecture/0-error-handling.md
@@ -10,3 +10,5 @@ paths:
- Adapters may try/catch only to convert third-party errors to typed exceptions
- Commands catch at action level only via `errorHandler.handle(error)`
- No silent errors, every failure surfaces to the user
+- A use-case serving requests in a long-lived process may catch to keep serving, and warns
+ through the logger
diff --git a/cli/.claude/rules/06-design-patterns/6-method-size.md b/cli/.claude/rules/06-design-patterns/6-method-size.md
index 061739052..5a6c69e9c 100644
--- a/cli/.claude/rules/06-design-patterns/6-method-size.md
+++ b/cli/.claude/rules/06-design-patterns/6-method-size.md
@@ -1,7 +1,6 @@
---
paths:
- - "src/application/use-cases/**/*.ts"
- - "src/domain/**/*.ts"
+ - "src/**/*.ts"
---
# Method Size Limit
diff --git a/cli/assets/configs/codex/config.toml b/cli/assets/configs/codex/config.toml
index 28a5f1edc..0d5a80504 100644
--- a/cli/assets/configs/codex/config.toml
+++ b/cli/assets/configs/codex/config.toml
@@ -1,2 +1 @@
-model = "gpt-5"
approval_policy = "on-request"
diff --git a/cli/biome.json b/cli/biome.json
index 6174733d6..0a6e7c1b1 100644
--- a/cli/biome.json
+++ b/cli/biome.json
@@ -1,10 +1,26 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.7/schema.json",
- "assist": { "actions": { "source": { "organizeImports": "on" } } },
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ },
"linter": {
"enabled": true,
"rules": {
- "recommended": true
+ "recommended": true,
+ "complexity": {
+ "noExcessiveLinesPerFunction": {
+ "level": "error",
+ "options": {
+ "maxLines": 20,
+ "skipBlankLines": true,
+ "skipIifes": true
+ }
+ }
+ }
}
},
"formatter": {
@@ -43,9 +59,59 @@
]
},
"overrides": [
+ {
+ "includes": ["tests/**", "scripts/**"],
+ "linter": {
+ "rules": {
+ "complexity": {
+ "noExcessiveLinesPerFunction": "off"
+ }
+ }
+ }
+ },
{
"includes": ["**/package.json"],
- "formatter": { "enabled": false }
+ "formatter": {
+ "enabled": false
+ }
+ },
+ {
+ "includes": ["src/application/commands/**"],
+ "linter": {
+ "rules": {
+ "complexity": {
+ "noExcessiveLinesPerFunction": "off"
+ }
+ }
+ }
+ },
+ {
+ "includes": [
+ "src/application/use-cases/framework/strategies/tool-contracts.ts",
+ "src/application/use-cases/install/install-content-section-use-case.ts",
+ "src/application/use-cases/install/install-ide-config-use-case.ts",
+ "src/application/use-cases/marketplace/marketplace-sync-settings-use-case.ts",
+ "src/application/use-cases/plugin/plugin-add-use-case.ts",
+ "src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.ts",
+ "src/application/use-cases/plugin/plugin-update-use-case.ts",
+ "src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts",
+ "src/application/use-cases/restore/restore-all-plugins-use-case.ts",
+ "src/application/use-cases/shared/restore-regular-files-use-case.ts",
+ "src/domain/capabilities/plugins-capability.ts",
+ "src/domain/formats/copilot-marketplace-catalog.ts",
+ "src/domain/formats/jsonc.ts",
+ "src/domain/models/plugin-source.ts",
+ "src/domain/tools/ai/copilot.ts",
+ "src/infrastructure/adapters/plugin-distribution-reader-adapter.ts",
+ "src/infrastructure/deps.ts"
+ ],
+ "linter": {
+ "rules": {
+ "complexity": {
+ "noExcessiveLinesPerFunction": "off"
+ }
+ }
+ }
}
]
}
diff --git a/cli/package.json b/cli/package.json
index fb89d62a9..9aefcef2d 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -44,7 +44,7 @@
"qs": ">=6.15.2"
}
},
- "bundleBudgetKB": 500,
+ "bundleBudgetKB": 560,
"scripts": {
"build": "tsup && node scripts/check-bundle-size.mjs",
"build:check-size": "node scripts/check-bundle-size.mjs",
diff --git a/cli/pnpm-workspace.yaml b/cli/pnpm-workspace.yaml
new file mode 100644
index 000000000..96039eab1
--- /dev/null
+++ b/cli/pnpm-workspace.yaml
@@ -0,0 +1,7 @@
+# Not a workspace. This file exists so pnpm stops searching upward: without it, the
+# repository root's own pnpm-workspace.yaml makes pnpm treat that root as this
+# project's workspace, and `cd cli && pnpm install` resolves the root's dependencies
+# instead of these. Measured, and the reason every CI job failed to find @types/node.
+#
+# Empty on purpose - cli/ has its own lockfile and installs alone.
+packages: []
diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs
index ee8819f96..686171c37 100644
--- a/cli/scripts/check-bundle-size.mjs
+++ b/cli/scripts/check-bundle-size.mjs
@@ -5,6 +5,10 @@ import { fileURLToPath } from "node:url";
const root = resolve(fileURLToPath(import.meta.url), "../..");
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
+// The budget exists to make growth visible, not to be a wall: it is raised
+// deliberately when a feature earns it, and the raise is what a reviewer sees.
+// 560 was set when measurement across five tools took the bundle to 500.8 KB,
+// leaving room to grow before the next conversation about it.
const budgetKB = pkg.bundleBudgetKB ?? 500;
const budgetBytes = budgetKB * 1024;
diff --git a/cli/src/application/commands/clean.ts b/cli/src/application/commands/clean.ts
index ab067c0eb..b4fa31676 100644
--- a/cli/src/application/commands/clean.ts
+++ b/cli/src/application/commands/clean.ts
@@ -30,7 +30,7 @@ export function registerCleanCommand(program: Command): void {
for (const tool of result.preview.tools) {
output.print(` ${tool.toolId}: ${tool.fileCount} files`);
}
- output.print(" manifest: .aidd/");
+ output.print(" manifest: .aidd/ (config.json, if present, is kept)");
const toolCount = result.preview.tools.length;
if (process.stdout.isTTY) {
output.print("No files removed.");
diff --git a/cli/src/application/commands/telemetry.ts b/cli/src/application/commands/telemetry.ts
new file mode 100644
index 000000000..b1a527e9a
--- /dev/null
+++ b/cli/src/application/commands/telemetry.ts
@@ -0,0 +1,182 @@
+import { homedir } from "node:os";
+import type { Command } from "commander";
+import {
+ DEFAULT_TELEMETRY_SCOPE,
+ TELEMETRY_SCOPES,
+ type TelemetryScope,
+} from "../../domain/capabilities/telemetry-capability.js";
+import { toCostReportEnvelope } from "../../domain/models/cost-report-envelope.js";
+import { DEFAULT_REPORT_DAYS, resolveReportPeriod } from "../../domain/models/report-period.js";
+import { createDeps } from "../../infrastructure/deps.js";
+import { printCostReport } from "../display/cost-report-display.js";
+import {
+ printLocalCostReadReport,
+ printTelemetryOffReport,
+ printTelemetryOnReport,
+} from "../display/telemetry-display.js";
+import { ErrorHandler } from "../error-handler.js";
+import { InvalidTelemetryReceivePortError, InvalidTelemetryScopeError } from "../errors.js";
+import { parseGlobalOptions } from "./global-options.js";
+
+export function parseTelemetryReceivePort(raw: string): number {
+ const port = Number(raw);
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
+ throw new InvalidTelemetryReceivePortError(raw);
+ }
+ return port;
+}
+
+export function parseTelemetryScope(raw: string | undefined): TelemetryScope {
+ if (raw === undefined) return DEFAULT_TELEMETRY_SCOPE;
+ if ((TELEMETRY_SCOPES as readonly string[]).includes(raw)) return raw as TelemetryScope;
+ throw new InvalidTelemetryScopeError(raw);
+}
+
+export function registerTelemetryCommand(program: Command): void {
+ const telemetry = program
+ .command("telemetry")
+ .description("Control whether AIDD may measure this project");
+
+ telemetry
+ .command("on")
+ .description("Turn on the AIDD telemetry switch and configure installed tools")
+ .option("--endpoint ", "OTEL export endpoint (reused from .aidd/config.json when omitted)")
+ .option(
+ "--scope ",
+ "Where a tool's export config is written (default: local)"
+ )
+ .option("--yes", "Confirm writing the git-tracked project-scope settings file", false)
+ .action(async (cmdOptions: { endpoint?: string; scope?: string; yes: boolean }) => {
+ const { verbose, output, projectRoot } = parseGlobalOptions(program);
+ const errorHandler = new ErrorHandler(output);
+ try {
+ const scope = parseTelemetryScope(cmdOptions.scope);
+ const deps = await createDeps(projectRoot, { verbose }, output);
+ const result = await deps.telemetryOnUseCase.execute({
+ projectRoot,
+ homeDir: homedir(),
+ endpoint: cmdOptions.endpoint,
+ scope,
+ confirmProjectScope: cmdOptions.yes,
+ });
+ printTelemetryOnReport(output, result);
+ } catch (error) {
+ errorHandler.handle(error);
+ }
+ });
+
+ telemetry
+ .command("receive")
+ .description("Listen for OTLP telemetry exports and store them under the AIDD telemetry sink")
+ .option("--port ", "Port to listen on (default: 4318, the OTLP/HTTP default)", "4318")
+ .action(async (cmdOptions: { port: string }) => {
+ const { verbose, output } = parseGlobalOptions(program);
+ const errorHandler = new ErrorHandler(output);
+ try {
+ const port = parseTelemetryReceivePort(cmdOptions.port);
+ const deps = await createDeps(process.cwd(), { verbose }, output);
+ const { rootDir } = await deps.receiveTelemetryUseCase.start();
+ output.info(`AIDD telemetry sink -> ${rootDir}`);
+ const { port: boundPort } = await deps.otlpHttpReceiverAdapter.listen(port);
+ output.info(`Listening for OTLP telemetry on http://localhost:${boundPort}`);
+ } catch (error) {
+ errorHandler.handle(error);
+ }
+ });
+
+ telemetry
+ .command("read")
+ .description(
+ "Read what sessions cost from the files their tools already wrote, with no process running"
+ )
+ .option(
+ "--session ",
+ "One session to read. Omitted, every session the run journal knows is read"
+ )
+ .action(async (cmdOptions: { session?: string }) => {
+ const { verbose, output, projectRoot } = parseGlobalOptions(program);
+ const errorHandler = new ErrorHandler(output);
+ try {
+ const deps = await createDeps(projectRoot, { verbose }, output);
+ const result = await deps.readLocalCostUseCase.execute(
+ cmdOptions.session === undefined ? {} : { sessionId: cmdOptions.session }
+ );
+ printLocalCostReadReport(output, result);
+ } catch (error) {
+ errorHandler.handle(error);
+ }
+ });
+
+ telemetry
+ .command("report")
+ .description(
+ "Report what a period, or one task inside it, cost — tokens, models and steps, with how strongly each was attributed"
+ )
+ .option("--from ", "First UTC day to report, as YYYY-MM-DD")
+ .option("--to ", "Last UTC day to report, as YYYY-MM-DD (default today)")
+ .option(
+ "--days ",
+ `How many days back to report, ending at --to (default ${DEFAULT_REPORT_DAYS})`
+ )
+ .option(
+ "--task ",
+ "Restrict to the sessions that wrote into this task, as /"
+ )
+ .option("--project ", "Restrict to this project")
+ .option("--step ", "Restrict to this step")
+ .option("--model ", "Restrict to this model")
+ .option("--tool ", "Restrict to this tool")
+ .option("--json", "Print one object a program can parse, instead of text for a person")
+ .action(
+ async (cmdOptions: {
+ from?: string;
+ to?: string;
+ days?: string;
+ task?: string;
+ project?: string;
+ step?: string;
+ model?: string;
+ tool?: string;
+ json?: boolean;
+ }) => {
+ const { verbose, output, projectRoot } = parseGlobalOptions(program);
+ const errorHandler = new ErrorHandler(output);
+ try {
+ // The clock is read once, here, and never again: everything downstream works from
+ // the two absolute days this resolves to, so the same call answers the same twice.
+ const period = resolveReportPeriod(cmdOptions, new Date());
+ const deps = await createDeps(projectRoot, { verbose }, output);
+ const report = await deps.reportCostUseCase.execute({
+ period,
+ ...(cmdOptions.task === undefined ? {} : { task: cmdOptions.task }),
+ filters: {
+ ...(cmdOptions.project === undefined ? {} : { project: cmdOptions.project }),
+ ...(cmdOptions.step === undefined ? {} : { step: cmdOptions.step }),
+ ...(cmdOptions.model === undefined ? {} : { model: cmdOptions.model }),
+ ...(cmdOptions.tool === undefined ? {} : { tool: cmdOptions.tool }),
+ },
+ });
+ // One value, two renderings. Neither derives a figure the other cannot see.
+ if (cmdOptions.json) output.print(JSON.stringify(toCostReportEnvelope(report), null, 2));
+ else printCostReport(output, report);
+ } catch (error) {
+ errorHandler.handle(error);
+ }
+ }
+ );
+
+ telemetry
+ .command("off")
+ .description("Turn off the AIDD telemetry switch and remove what `aidd telemetry on` wrote")
+ .action(async () => {
+ const { verbose, output, projectRoot } = parseGlobalOptions(program);
+ const errorHandler = new ErrorHandler(output);
+ try {
+ const deps = await createDeps(projectRoot, { verbose }, output);
+ const result = await deps.telemetryOffUseCase.execute({ projectRoot });
+ printTelemetryOffReport(output, result);
+ } catch (error) {
+ errorHandler.handle(error);
+ }
+ });
+}
diff --git a/cli/src/application/display/cost-report-display.ts b/cli/src/application/display/cost-report-display.ts
new file mode 100644
index 000000000..5f1213c9e
--- /dev/null
+++ b/cli/src/application/display/cost-report-display.ts
@@ -0,0 +1,383 @@
+import type {
+ CostReport,
+ CostReportAttributionRow,
+ CostReportDayRow,
+ CostReportEmptySelection,
+ CostReportFilterName,
+ CostReportFilters,
+ CostReportProjectRow,
+ CostReportStepRow,
+ CostReportTaskAttributionRow,
+ CostReportToolRow,
+ CostTotals,
+} from "../../domain/models/cost-report.js";
+import { fromMicroUsd } from "../../domain/models/cost-report.js";
+import type { StepAttributionSource } from "../../domain/models/step-attribution.js";
+import type { TaskAttributionSource } from "../../domain/models/task-attribution.js";
+import { getAiToolConfig } from "../../domain/tools/registry.js";
+import type { CLIOutput } from "../output.js";
+
+/** What each strength of attribution is called where a person reads it. `unattributed`
+ * says nothing could attribute this, and deliberately not that the work ran outside every
+ * step: on at least one measured tool the two are indistinguishable, and the stronger
+ * reading would be a fact this layer invented. */
+const ATTRIBUTION_LABELS: Record = {
+ "tool-stated": "stated by the tool",
+ "journal-interval": "from a journal interval",
+ unattributed: "unattributed",
+};
+
+const TASK_ATTRIBUTION_LABELS: Record = {
+ declared: "declared by the flow",
+ inferred: "inferred from a written file",
+};
+
+/** Printed where a figure is genuinely not known, never as `$0.00`. A tool whose own files
+ * carry no amount has an unknown cost, not a free one. */
+const UNKNOWN_AMOUNT = "amount unknown";
+/** A covered tool with no records, and a wholly unfiltered period with none at all.
+ * Distinct from both an unknown amount and a zero: this one really did measure nothing,
+ * and saying so is the only reading the records support. */
+const NOTHING_MEASURED = "nothing in this period";
+/** The same zero, under a selection narrower than the whole period. `task` and every
+ * generic filter already narrow the record set before any breakdown is computed, so a
+ * zero row under either is caused by the selection, not by real idleness - saying
+ * "period" there would be a false statement about time. */
+const NOTHING_IN_SELECTION = "nothing in this selection";
+/** What a tool's `sessionTotals` figure is called wherever it is printed - never merged
+ * into the request-based figure beside it, and never called "cost" or "requests" since it
+ * is neither. */
+const SESSION_TOTAL_LABEL = "session total, not requests";
+const LABEL_WIDTH = 26;
+const NO_KNOWN_PROJECT = "no known project";
+
+// A year asked for by day is 365 rows - the envelope always carries every one of them, but
+// a terminal is not the place to read that many. Above this, the text rendering names the
+// count and points at --json rather than printing a screen nobody can scan. Must match
+// render.js's own MAX_PRINTED_DAYS: the byte-compare e2e test holds the two to it.
+const MAX_PRINTED_DAYS = 31;
+
+function formatCount(value: number): string {
+ return value.toLocaleString("en-US");
+}
+
+function formatAmount(microUsd: number): string {
+ return `$${fromMicroUsd(microUsd).toFixed(2)}`;
+}
+
+/** Every token a record counted, across the four disjoint counters — a tool's `input` is
+ * exclusive of its cache figures on every reader here, so adding them counts nothing
+ * twice. */
+function totalTokens(totals: CostTotals): number {
+ return (
+ (totals.inputTokens ?? 0) +
+ (totals.outputTokens ?? 0) +
+ (totals.cacheReadTokens ?? 0) +
+ (totals.cacheCreationTokens ?? 0)
+ );
+}
+
+/** What a share is taken of. Cost where the period has one, tokens where it does not — a
+ * period made only of tools that carry no amount still breaks down, by the quantity it
+ * does have. Named in the output so nobody has to guess which. */
+function shareBasis(totals: CostTotals): { readonly label: string; readonly of: number } {
+ return totals.costMicroUsd === undefined
+ ? { label: "of tokens", of: totalTokens(totals) }
+ : { label: "of cost", of: totals.costMicroUsd };
+}
+
+function shareOf(totals: CostTotals, basis: number, useCost: boolean): string {
+ if (basis === 0) return " - ";
+ const part = useCost ? (totals.costMicroUsd ?? 0) : totalTokens(totals);
+ return `${Math.round((part / basis) * 100)
+ .toString()
+ .padStart(3)}%`;
+}
+
+function pad(label: string): string {
+ return label.padEnd(LABEL_WIDTH);
+}
+
+/** `task` and the four generic filters both narrow the record set before any breakdown
+ * runs, so either one - alone or together - means every zero downstream is the selection
+ * talking, not the period. Every row measured against this reads unambiguously: nothing
+ * a filter can produce here escapes being counted as in-scope or out, so there is no row
+ * this call cannot decide for. */
+function hasSelection(report: Pick): boolean {
+ return report.task !== undefined || report.filters !== undefined;
+}
+
+function nothingLabel(report: Pick): string {
+ return hasSelection(report) ? NOTHING_IN_SELECTION : NOTHING_MEASURED;
+}
+
+/** `name=value` for every active generic filter, in the fixed order `cost-report.ts`
+ * gives them - empty for an unfiltered period. */
+function filtersSuffix(filters: CostReportFilters | undefined): string {
+ if (!filters) return "";
+ const parts = Object.entries(filters).map(([name, value]) => `${name}=${value}`);
+ return parts.length === 0 ? "" : ` filters: ${parts.join(", ")}`;
+}
+
+// What "never known" means differs by filter: `task` and `tool` are checked against
+// journals and a declared list, never against a record, so saying "no record" for either
+// would claim a check this layer never ran.
+const UNKNOWN_REASON: Partial> = {
+ task: "no journal has ever declared it or written into it",
+ tool: "it is not one of the tools this build knows",
+};
+
+function unknownReason(filter: CostReportFilterName): string {
+ return UNKNOWN_REASON[filter] ?? `no record has ever named this ${filter}`;
+}
+
+/** What a filter matching nothing says, told apart from a period that genuinely holds no
+ * work: that case never reaches here, since the report only ever carries an
+ * `emptySelection` when a filter - not the period itself - is what emptied it. */
+function emptySelectionMessage({
+ filter,
+ value,
+ known,
+ combination,
+}: CostReportEmptySelection): string {
+ if (!known) return ` ${filter} '${value}' matched nothing — ${unknownReason(filter)}`;
+ if (combination)
+ return ` ${filter} '${value}' matched nothing combined with the rest of this selection`;
+ return ` ${filter} '${value}' matched nothing in this selection — known, but no work here`;
+}
+
+function printTotals(output: CLIOutput, report: CostReport): void {
+ const { totals } = report;
+ if (totals.requests === 0) {
+ output.print(` ${pad("sessions")}${formatCount(report.sessions)}`);
+ output.print(` ${pad("requests")}${nothingLabel(report)}`);
+ return;
+ }
+ const tokens = totalTokens(totals);
+ const cacheShare = tokens === 0 ? 0 : Math.round(((totals.cacheReadTokens ?? 0) / tokens) * 100);
+ output.print(` ${pad("sessions")}${formatCount(report.sessions)}`);
+ output.print(` ${pad("requests")}${formatCount(totals.requests)}`);
+ output.print(` ${pad("tokens")}${formatCount(tokens)} ${cacheShare}% cache`);
+ output.print(
+ ` ${pad("cost")}${totals.costMicroUsd === undefined ? UNKNOWN_AMOUNT : formatAmount(totals.costMicroUsd)}`
+ );
+ if (report.activeTimeSeconds !== undefined) {
+ const minutes = Math.round(report.activeTimeSeconds / 60);
+ output.print(
+ ` ${pad("active time")}${formatCount(minutes)} min per session; not attributable to steps`
+ );
+ }
+}
+
+function figureFor(totals: CostTotals, useCost: boolean): string {
+ if (!useCost) return `${formatCount(totalTokens(totals))} tokens`;
+ return totals.costMicroUsd === undefined ? UNKNOWN_AMOUNT : formatAmount(totals.costMicroUsd);
+}
+
+function printStepRows(
+ output: CLIOutput,
+ rows: readonly CostReportStepRow[],
+ basis: number,
+ useCost: boolean
+): void {
+ for (const row of rows) {
+ const name = row.step ?? ATTRIBUTION_LABELS.unattributed;
+ const strength = row.step === undefined ? "" : ` ${ATTRIBUTION_LABELS[row.attribution]}`;
+ output.print(
+ ` ${pad(name)}${shareOf(row.totals, basis, useCost)} ${figureFor(row.totals, useCost)}${strength}`
+ );
+ }
+}
+
+function printAttributionRows(
+ output: CLIOutput,
+ rows: readonly CostReportAttributionRow[],
+ basis: number,
+ useCost: boolean
+): void {
+ for (const row of rows) {
+ output.print(
+ ` ${pad(ATTRIBUTION_LABELS[row.attribution])}${shareOf(row.totals, basis, useCost)}`
+ );
+ }
+}
+
+/** Every declared tool, including the ones that can say nothing. A tool missing from this
+ * list is a tool a reader takes for one that did nothing, and for an unreadable one that
+ * is the false zero this whole layer exists to prevent. */
+function printToolRows(
+ output: CLIOutput,
+ rows: readonly CostReportToolRow[],
+ report: Pick
+): void {
+ for (const row of rows) {
+ const name = getAiToolConfig(row.tool).displayName;
+ if (row.coverage === "not-covered") {
+ output.print(` ${pad(name)}not covered${row.reason ? ` — ${row.reason}` : ""}`);
+ continue;
+ }
+ if (row.totals.requests === 0 && row.sessionTotals) {
+ const tokens = `${formatCount(totalTokens(row.sessionTotals))} tokens (${SESSION_TOTAL_LABEL})`;
+ output.print(` ${pad(name)}${tokens}${row.reason ? ` — ${row.reason}` : ""}`);
+ continue;
+ }
+ if (row.totals.requests === 0) {
+ output.print(
+ ` ${pad(name)}${nothingLabel(report)}${row.reason ? ` — ${row.reason}` : ""}`
+ );
+ continue;
+ }
+ const figure =
+ row.totals.costMicroUsd === undefined
+ ? UNKNOWN_AMOUNT
+ : formatAmount(row.totals.costMicroUsd);
+ const tokens = `${formatCount(totalTokens(row.totals))} tokens`;
+ output.print(` ${pad(name)}${figure} ${tokens}${row.reason ? ` — ${row.reason}` : ""}`);
+ }
+}
+
+function printCaveats(output: CLIOutput, report: CostReport): void {
+ if (report.undatedRecords > 0) {
+ output.print(
+ ` ${formatCount(report.undatedRecords)} records carry no moment and are in no period`
+ );
+ }
+ if (report.unreadableLines > 0) {
+ output.print(` ${formatCount(report.unreadableLines)} lines could not be read`);
+ }
+}
+
+/** A breakdown reads as a group: a blank line, a heading naming what its shares are taken
+ * of, then its rows. Empty groups print nothing at all rather than a heading over silence. */
+interface Basis {
+ readonly label: string;
+ readonly of: number;
+ readonly useCost: boolean;
+}
+
+/** Only where `--task` narrowed the report - a session without one carries no per-record
+ * task identity to break down (see metrics-contract.md), so there is nothing here to print
+ * for the unfiltered period. */
+function printTaskAttribution(output: CLIOutput, report: CostReport, basis: Basis): void {
+ if (report.taskAttributionMix === undefined) return;
+ output.print("");
+ output.print(` ticket known ${basis.label}`);
+ printTaskAttributionRows(output, report.taskAttributionMix, basis.of, basis.useCost);
+}
+
+function printTaskAttributionRows(
+ output: CLIOutput,
+ rows: readonly CostReportTaskAttributionRow[],
+ basis: number,
+ useCost: boolean
+): void {
+ for (const row of rows) {
+ output.print(
+ ` ${pad(TASK_ATTRIBUTION_LABELS[row.attribution])}${shareOf(row.totals, basis, useCost)}`
+ );
+ }
+}
+
+function printStepsAndAttribution(output: CLIOutput, report: CostReport, basis: Basis): void {
+ if (report.bySteps.length === 0) return;
+ output.print("");
+ output.print(` by step ${basis.label}`);
+ printStepRows(output, report.bySteps, basis.of, basis.useCost);
+ output.print("");
+ output.print(` attribution ${basis.label}`);
+ printAttributionRows(output, report.attributionMix, basis.of, basis.useCost);
+}
+
+function printModels(output: CLIOutput, report: CostReport, basis: Basis): void {
+ if (report.byModels.length === 0) return;
+ output.print("");
+ output.print(` by model ${basis.label}`);
+ for (const row of report.byModels) {
+ const share = shareOf(row.totals, basis.of, basis.useCost);
+ output.print(` ${pad(row.model)}${share} ${figureFor(row.totals, basis.useCost)}`);
+ }
+}
+
+function printProjects(
+ output: CLIOutput,
+ rows: readonly CostReportProjectRow[],
+ basis: Basis
+): void {
+ if (rows.length === 0) return;
+ output.print("");
+ output.print(` by project ${basis.label}`);
+ for (const row of rows) {
+ const name = row.project ?? NO_KNOWN_PROJECT;
+ const share = shareOf(row.totals, basis.of, basis.useCost);
+ output.print(` ${pad(name)}${share} ${figureFor(row.totals, basis.useCost)}`);
+ }
+}
+
+/** Chronological, never sorted by size: a series read out of order is not a series. Above
+ * `MAX_PRINTED_DAYS`, a person reads a count and where to get the rest - the envelope
+ * still carries every day, since suppressing a row there would be the same false
+ * continuity this layer refuses everywhere else. */
+function printDays(
+ output: CLIOutput,
+ rows: readonly CostReportDayRow[],
+ report: Pick
+): void {
+ if (rows.length === 0) return;
+ output.print("");
+ output.print(" by day");
+ if (rows.length > MAX_PRINTED_DAYS) {
+ output.print(
+ ` ${formatCount(rows.length)} days in this period — see --json for the daily breakdown`
+ );
+ return;
+ }
+ for (const row of rows) {
+ if (row.totals.requests === 0) {
+ output.print(` ${pad(row.day)}${nothingLabel(report)}`);
+ continue;
+ }
+ const figure =
+ row.totals.costMicroUsd === undefined
+ ? UNKNOWN_AMOUNT
+ : formatAmount(row.totals.costMicroUsd);
+ output.print(` ${pad(row.day)}${figure} ${formatCount(totalTokens(row.totals))} tokens`);
+ }
+}
+
+/**
+ * One period's cost, as a person reads it.
+ *
+ * Prints no amount it was not given: the rates live outside this repository, so a tool
+ * whose files carry none says so rather than showing zero. Prints every declared tool,
+ * including the ones nothing here can read, with the reason from their own declaration.
+ * Carries no prompt, code, diff or file path - a task appears by its identity, never by
+ * the paths it was derived from.
+ */
+export function printCostReport(output: CLIOutput, report: CostReport): void {
+ const scope = report.task === undefined ? "period" : `task ${report.task}`;
+ output.print(`${scope} ${report.fromDay} to ${report.toDay}${filtersSuffix(report.filters)}`);
+ output.print("");
+ if (report.emptySelection !== undefined) {
+ output.print(emptySelectionMessage(report.emptySelection));
+ output.print("");
+ }
+ printTotals(output, report);
+
+ // A filter-emptied selection has nothing under any breakdown to show - every row would
+ // read "nothing in this period", which is exactly the false zero this layer refuses.
+ if (report.emptySelection === undefined) {
+ const basis: Basis = {
+ ...shareBasis(report.totals),
+ useCost: report.totals.costMicroUsd !== undefined,
+ };
+ printTaskAttribution(output, report, basis);
+ printStepsAndAttribution(output, report, basis);
+ printModels(output, report, basis);
+ printProjects(output, report.byProjects, basis);
+ output.print("");
+ output.print(" by tool");
+ printToolRows(output, report.byTools, report);
+ printDays(output, report.byDays, report);
+ }
+ printCaveats(output, report);
+}
diff --git a/cli/src/application/display/telemetry-display.ts b/cli/src/application/display/telemetry-display.ts
new file mode 100644
index 000000000..c9d696190
--- /dev/null
+++ b/cli/src/application/display/telemetry-display.ts
@@ -0,0 +1,85 @@
+import { getAiToolConfig } from "../../domain/tools/registry.js";
+import type { CLIOutput } from "../output.js";
+import type {
+ LocalCostToolStatus,
+ ReadLocalCostResult,
+} from "../use-cases/telemetry/read-local-cost-use-case.js";
+import type { TelemetryOffResult } from "../use-cases/telemetry/telemetry-off-use-case.js";
+import type {
+ TelemetryOnResult,
+ TelemetryToolReport,
+} from "../use-cases/telemetry/telemetry-on-use-case.js";
+
+const STATUS_LABELS: Record = {
+ enabled: "enabled",
+ "not-installed": "not installed",
+ "not-yet-supported": "not yet supported",
+ "not-a-file": "not a file",
+ "cannot-enable": "cannot be enabled by us",
+};
+
+const LOCAL_COST_STATUS_LABELS: Record = {
+ found: "read",
+ empty: "read, nothing found",
+ // Never "nothing found": this tool has no trace of the session, so it can say nothing
+ // about what it cost. Printing the two alike would let a session read as free.
+ "not-found": "no session found",
+ // Its reader failed, so nothing is known about this tool for this session and something
+ // is wrong. Distinct from "no session found", where nothing is known and nothing is wrong.
+ unreadable: "could not be read",
+ "not-covered": "not covered",
+};
+
+export function printTelemetryOnReport(output: CLIOutput, result: TelemetryOnResult): void {
+ const switchLabel = result.switchChanged ? "on" : "already on";
+ output.success(`AIDD telemetry: ${switchLabel} (${result.switchPath})`);
+ output.print(`Endpoint: ${result.endpoint}`);
+ for (const report of result.toolReports) {
+ const name = getAiToolConfig(report.tool).displayName;
+ output.print(` ${name}: ${STATUS_LABELS[report.status]} — ${report.detail}`);
+ }
+ output.print(
+ "Run `aidd telemetry receive` to capture what is exported — without it, nothing is stored."
+ );
+}
+
+export function printLocalCostReadReport(output: CLIOutput, result: ReadLocalCostResult): void {
+ // A sweep prints one line per tool, never one per tool per session: twenty sessions
+ // times five tools is a hundred lines nobody reads. How many sessions it covered is the
+ // fact that changes, so it leads.
+ const yielded = result.sessions.filter((session) =>
+ session.toolReports.some((report) => report.recordsFound > 0)
+ ).length;
+ if (result.sessions.length === 0) {
+ output.print(" No session journalled yet — nothing to read.");
+ return;
+ }
+ output.print(
+ ` ${result.sessions.length} session${result.sessions.length === 1 ? "" : "s"} read, ${yielded} with records`
+ );
+ for (const report of result.toolReports) {
+ const name = getAiToolConfig(report.tool).displayName;
+ const label = LOCAL_COST_STATUS_LABELS[report.status];
+ const counts =
+ report.status === "found" ? ` (${report.recordsStored} new of ${report.recordsFound})` : "";
+ const reason = report.reason ? ` — ${report.reason}` : "";
+ // Never folded into the status: a tool that read most sessions and failed one reports
+ // as read, and a failure visible only in the status would vanish exactly there.
+ const failures =
+ report.sessionsFailed > 0
+ ? ` [${report.sessionsFailed} session${report.sessionsFailed === 1 ? "" : "s"} could not be read: ${report.failureReason}]`
+ : "";
+ output.print(` ${name}: ${label}${counts}${reason}${failures}`);
+ }
+}
+
+export function printTelemetryOffReport(output: CLIOutput, result: TelemetryOffResult): void {
+ const switchLabel = result.switchChanged ? "off" : "already off";
+ output.success(`AIDD telemetry: ${switchLabel} (${result.switchPath})`);
+ if (result.removedFiles.length === 0) {
+ output.print("Nothing tracked to remove.");
+ } else {
+ for (const file of result.removedFiles) output.print(` Removed telemetry entries: ${file}`);
+ }
+ for (const reminder of result.manualUnsetReminders) output.print(reminder);
+}
diff --git a/cli/src/application/errors.ts b/cli/src/application/errors.ts
index 42faecdbc..ceac67f35 100644
--- a/cli/src/application/errors.ts
+++ b/cli/src/application/errors.ts
@@ -58,3 +58,34 @@ export class InvalidCategoryError extends Error {
this.name = "InvalidCategoryError";
}
}
+
+export class InvalidTelemetryScopeError extends Error {
+ constructor(scope: string) {
+ super(`Invalid --scope '${scope}'. Expected 'local', 'project', or 'user'.`);
+ this.name = "InvalidTelemetryScopeError";
+ }
+}
+
+export class InvalidTelemetryReceivePortError extends Error {
+ constructor(value: string) {
+ super(`Invalid --port '${value}'. Expected an integer between 0 and 65535.`);
+ this.name = "InvalidTelemetryReceivePortError";
+ }
+}
+
+export class InvalidTelemetryPeriodError extends Error {
+ constructor(value: string, maxDays: number) {
+ super(`Invalid --days '${value}'. Expected an integer between 1 and ${maxDays}.`);
+ this.name = "InvalidTelemetryPeriodError";
+ }
+}
+
+export class TelemetryProjectScopeRequiresYesError extends Error {
+ constructor(settingsPath: string) {
+ super(
+ `--scope project writes the git-tracked ${settingsPath}, turning telemetry on for ` +
+ "everyone who clones. Pass --yes to confirm."
+ );
+ this.name = "TelemetryProjectScopeRequiresYesError";
+ }
+}
diff --git a/cli/src/application/use-cases/clean-use-case.ts b/cli/src/application/use-cases/clean-use-case.ts
index 06cfe053a..df7918fd1 100644
--- a/cli/src/application/use-cases/clean-use-case.ts
+++ b/cli/src/application/use-cases/clean-use-case.ts
@@ -5,7 +5,7 @@ import {
type MergeFileEntry,
removeEntriesFromJson,
} from "../../domain/models/merge.js";
-import { AIDD_DIR } from "../../domain/models/paths.js";
+import { AIDD_CONFIG_FILENAME, AIDD_DIR, PLUGIN_CACHE_SUBDIR } from "../../domain/models/paths.js";
import { isAiToolId } from "../../domain/models/tool-ids.js";
import type { FileReader } from "../../domain/ports/file-reader.js";
import type { FileWriter } from "../../domain/ports/file-writer.js";
@@ -52,11 +52,32 @@ export class CleanUseCase {
const dryRunResult = await this.confirmOrDryRun(options, preview);
if (dryRunResult !== null) return dryRunResult;
const deleted = await this.deleteAllToolFiles(manifest, options.projectRoot);
- await this.fs.deleteDirectory(join(options.projectRoot, AIDD_DIR));
+ await this.removeAiddState(options.projectRoot);
await this.gitignoreUseCase.remove(options.projectRoot, [`${AIDD_DIR}/cache/`]);
return { dryRun: false, manifestFound: true, preview, fileCount: deleted };
}
+ // config.json is the committed telemetry switch: a file clean did not write,
+ // so clean never removes it. Every directory clean did write must go before
+ // the emptiness check, or its own presence blocks a removal that should
+ // happen.
+ private async removeAiddState(projectRoot: string): Promise {
+ const aiddDir = join(projectRoot, AIDD_DIR);
+ const configKept = await this.fs.fileExists(join(aiddDir, AIDD_CONFIG_FILENAME));
+
+ await this.fs.deleteDirectory(join(aiddDir, "cache"));
+ await this.fs.deleteDirectory(join(projectRoot, PLUGIN_CACHE_SUBDIR));
+ await this.manifestRepo.delete();
+
+ if (!(await this.fs.fileExists(aiddDir))) return;
+ const remaining = await this.fs.listDirectory(aiddDir);
+ if (remaining.length === 0) {
+ await this.fs.deleteDirectory(aiddDir);
+ return;
+ }
+ if (configKept) this.logger.info(`Kept ${AIDD_DIR}/${AIDD_CONFIG_FILENAME}`);
+ }
+
private buildPreview(manifest: Manifest): CleanPreview {
const tools = manifest.getInstalledToolIds().map((toolId) => ({
toolId,
diff --git a/cli/src/application/use-cases/framework/shared-plugin-helpers.ts b/cli/src/application/use-cases/framework/assert-no-tools-placeholder.ts
similarity index 100%
rename from cli/src/application/use-cases/framework/shared-plugin-helpers.ts
rename to cli/src/application/use-cases/framework/assert-no-tools-placeholder.ts
diff --git a/cli/src/application/use-cases/framework/strategies/codex-marketplace-catalog.ts b/cli/src/application/use-cases/framework/strategies/codex-marketplace-catalog.ts
new file mode 100644
index 000000000..1fccb965e
--- /dev/null
+++ b/cli/src/application/use-cases/framework/strategies/codex-marketplace-catalog.ts
@@ -0,0 +1,47 @@
+// ── Codex-native marketplace catalog (for `codex plugin marketplace add`) ──────
+// Shape verified 2026-07-05 against https://github.com/openai/plugins
+// .agents/plugins/marketplace.json and https://developers.openai.com/codex/plugins/build.
+
+/** Default category when the source marketplace entry does not specify one. */
+export const CODEX_DEFAULT_CATEGORY = "Developer Tools";
+/**
+ * Default per-plugin auth policy. AIDD plugins bundle skills/agents/hooks with no
+ * external OAuth, so auth is deferred to first use rather than forced at install.
+ */
+export const CODEX_DEFAULT_AUTHENTICATION = "ON_USE";
+const CODEX_INSTALLATION_AVAILABLE = "AVAILABLE";
+
+/**
+ * Build a Codex marketplace catalog: `{ name, interface: { displayName }, plugins }`.
+ * `displayName` falls back to the marketplace name when the source omits it.
+ */
+export function buildCodexMarketplace(
+ source: { name: string; displayName?: string },
+ pluginEntries: readonly Record[]
+): Record {
+ const displayName = typeof source.displayName === "string" ? source.displayName : source.name;
+ return { name: source.name, interface: { displayName }, plugins: pluginEntries };
+}
+
+/**
+ * Build a single Codex marketplace entry. `installation`/`authentication`/`category`
+ * are required per the plugin-creator spec; `authentication` and `category` accept a
+ * source-entry override, else fall back to the AIDD-shaped defaults.
+ */
+export function buildCodexMarketplaceEntry(
+ name: string,
+ srcEntry: Record | undefined
+): Record {
+ const authentication =
+ typeof srcEntry?.authentication === "string"
+ ? srcEntry.authentication
+ : CODEX_DEFAULT_AUTHENTICATION;
+ const category =
+ typeof srcEntry?.category === "string" ? srcEntry.category : CODEX_DEFAULT_CATEGORY;
+ return {
+ name,
+ source: { source: "local", path: `./plugins/${name}` },
+ policy: { installation: CODEX_INSTALLATION_AVAILABLE, authentication },
+ category,
+ };
+}
diff --git a/cli/src/application/use-cases/framework/strategies/default-plugin-catalog.ts b/cli/src/application/use-cases/framework/strategies/default-plugin-catalog.ts
new file mode 100644
index 000000000..b9729545f
--- /dev/null
+++ b/cli/src/application/use-cases/framework/strategies/default-plugin-catalog.ts
@@ -0,0 +1,69 @@
+import type { PluginPresenceFlags } from "./plugin-source-tree-reader.js";
+
+export interface SynthesizeDefaultPluginManifestOpts {
+ /** When true, include `agents` as a list of `./agents/*.md` file paths if agents are present. */
+ readonly agentsField: boolean;
+ /**
+ * Whether the tool needs `hooks` to point at the standard `hooks/hooks.json`.
+ *
+ * Codex does. Claude Code loads that path by its own convention and, since 2.1.240,
+ * rejects the plugin outright when a manifest names it as well: "Duplicate hooks file
+ * detected ... The standard hooks/hooks.json is loaded automatically, so manifest.hooks
+ * should only reference additional hook files." The hooks still fire, so the plugin reads
+ * as failed while working — measured, and worse than either honest outcome.
+ */
+ readonly hooksField: boolean;
+}
+
+export function synthesizeDefaultPluginManifest(
+ source: Record,
+ presence: PluginPresenceFlags,
+ opts: SynthesizeDefaultPluginManifestOpts
+): Record {
+ const manifest: Record = {};
+ if (typeof source.name === "string") manifest.name = source.name;
+ if (typeof source.description === "string") manifest.description = source.description;
+ if (typeof source.version === "string") manifest.version = source.version;
+ if (typeof source.author === "string" || typeof source.author === "object")
+ manifest.author = source.author;
+ if (typeof source.homepage === "string") manifest.homepage = source.homepage;
+ if (typeof source.repository === "string") manifest.repository = source.repository;
+ if (typeof source.license === "string") manifest.license = source.license;
+ if (Array.isArray(source.keywords)) manifest.keywords = source.keywords;
+ if (opts.agentsField && presence.agentsList.length > 0)
+ manifest.agents = presence.agentsList.map((n) => `./agents/${n}`);
+ if (presence.skillsList.length > 0)
+ manifest.skills = presence.skillsList.map((n) => `./skills/${n}`);
+ if (opts.hooksField && presence.hasHooksJson) manifest.hooks = "./hooks/hooks.json";
+ if (presence.hasMcpJson) manifest.mcpServers = "./.mcp.json";
+ return manifest;
+}
+
+export function buildDefaultMarketplace(
+ source: { name: string; version?: string; description?: string; owner?: unknown },
+ pluginEntries: readonly Record[]
+): Record {
+ const obj: Record = { name: source.name };
+ if (typeof source.version === "string") obj.version = source.version;
+ if (typeof source.description === "string") obj.description = source.description;
+ if (source.owner !== undefined) obj.owner = source.owner;
+ obj.plugins = pluginEntries;
+ return obj;
+}
+
+export function buildDefaultCatalogEntry(
+ name: string,
+ description: string,
+ version: string,
+ srcEntry: Record | undefined
+): Record {
+ const entry: Record = {
+ name,
+ source: `./plugins/${name}`,
+ description,
+ version,
+ };
+ if (typeof srcEntry?.strict === "boolean") entry.strict = srcEntry.strict;
+ if (typeof srcEntry?.recommended === "boolean") entry.recommended = srcEntry.recommended;
+ return entry;
+}
diff --git a/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts b/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts
index f701a21fa..71102a3ef 100644
--- a/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts
+++ b/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts
@@ -18,7 +18,7 @@ import type {
ArtifactContract,
ToolBuildContract,
} from "../../../../domain/tools/build-contract.js";
-import { assertNoToolsPlaceholder } from "../shared-plugin-helpers.js";
+import { assertNoToolsPlaceholder } from "../assert-no-tools-placeholder.js";
import type { BuildOutputStrategy, SourceMarketplace } from "./build-output-strategy.js";
export class FlatBuildStrategy implements BuildOutputStrategy {
@@ -85,7 +85,9 @@ export class FlatBuildStrategy implements BuildOutputStrategy {
}
const hooksSrc = join(pluginSrc, PLUGIN_HOOKS_RELATIVE);
if (!(await this.fs.fileExists(hooksSrc))) return 0;
- const jsonCount = await this.writeFlatHooksJson(artifact, pluginName, hooksSrc);
+ const jsonCount = artifact.skipHooksJson
+ ? 0
+ : await this.writeFlatHooksJson(artifact, pluginName, hooksSrc);
const scriptCount = await this.writeFlatHooksScripts(artifact, pluginName, pluginSrc);
return jsonCount + scriptCount;
}
diff --git a/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts b/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts
index 18798641a..9c80abb26 100644
--- a/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts
+++ b/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts
@@ -14,9 +14,10 @@ import type {
SourcePluginEntryRef,
ToolBuildContract,
} from "../../../../domain/tools/build-contract.js";
-import { assertNoToolsPlaceholder } from "../shared-plugin-helpers.js";
+import { assertNoToolsPlaceholder } from "../assert-no-tools-placeholder.js";
import type { BuildOutputStrategy, SourceMarketplace } from "./build-output-strategy.js";
-import { detectPluginPresenceFlags, writeSkillTree } from "./marketplace-strategy-helpers.js";
+import { detectPluginPresenceFlags } from "./plugin-source-tree-reader.js";
+import { writeSkillTree } from "./write-skill-tree.js";
export class MarketplaceBuildStrategy implements BuildOutputStrategy {
constructor(
diff --git a/cli/src/application/use-cases/framework/strategies/marketplace-strategy-helpers.ts b/cli/src/application/use-cases/framework/strategies/marketplace-strategy-helpers.ts
deleted file mode 100644
index 10c87a552..000000000
--- a/cli/src/application/use-cases/framework/strategies/marketplace-strategy-helpers.ts
+++ /dev/null
@@ -1,266 +0,0 @@
-import { basename, join, relative } from "node:path";
-import { InvalidSourceMarketplaceError } from "../../../../domain/errors.js";
-import { rewriteRelativeLinks } from "../../../../domain/formats/relative-link-rewrite.js";
-import {
- PLUGIN_AGENT_INPUT_EXT,
- PLUGIN_HOOKS_RELATIVE,
- PLUGIN_MCP_RELATIVE,
- PLUGIN_SKILL_ENTRY_FILE,
-} from "../../../../domain/models/framework-build.js";
-import type { FileReader } from "../../../../domain/ports/file-reader.js";
-import type { FileWriter } from "../../../../domain/ports/file-writer.js";
-import { assertNoToolsPlaceholder } from "../shared-plugin-helpers.js";
-
-type SkillContentTransform = (content: string, plugin: string, basename: string) => string;
-export interface PluginPresenceFlags {
- readonly hasAgents: boolean;
- /** Agent markdown files relative to the plugin's `agents/` dir (e.g. "planner.md"), sorted. */
- readonly agentsList: readonly string[];
- readonly skillsList: readonly string[];
- readonly hasHooksJson: boolean;
- readonly hasMcpJson: boolean;
-}
-
-export async function listAgentFiles(
- fs: FileReader,
- agentsDir: string
-): Promise {
- if (!(await fs.fileExists(agentsDir))) return [];
- const files = await fs.listFilesRecursive(agentsDir);
- return files
- .filter((f) => f.endsWith(PLUGIN_AGENT_INPUT_EXT))
- .map((f) => relative(agentsDir, f).replace(/\\/g, "/"))
- .sort();
-}
-
-export async function listSkillNames(
- fs: FileReader,
- pluginSrc: string
-): Promise {
- const skillsDir = join(pluginSrc, "skills");
- if (!(await fs.fileExists(skillsDir))) return [];
- const files = await fs.listFilesRecursive(skillsDir);
- const names = new Set();
- for (const f of files) {
- if (
- !f.endsWith(`/${PLUGIN_SKILL_ENTRY_FILE}`) &&
- !f.endsWith(`\\${PLUGIN_SKILL_ENTRY_FILE}`) &&
- !f.endsWith(PLUGIN_SKILL_ENTRY_FILE)
- ) {
- continue;
- }
- const rel = relative(skillsDir, f);
- const parts = rel.replace(/\\/g, "/").split("/");
- if (parts.length >= 2) names.add(parts[0]);
- }
- return [...names].sort();
-}
-
-export async function detectPluginPresenceFlags(
- fs: FileReader,
- pluginSrc: string
-): Promise {
- const agentsDir = join(pluginSrc, "agents");
- const agentsList = await listAgentFiles(fs, agentsDir);
- const skillsList = await listSkillNames(fs, pluginSrc);
- const hasHooksJson = await fs.fileExists(join(pluginSrc, PLUGIN_HOOKS_RELATIVE));
- const hasMcpJson = await fs.fileExists(join(pluginSrc, PLUGIN_MCP_RELATIVE));
- return { hasAgents: agentsList.length > 0, agentsList, skillsList, hasHooksJson, hasMcpJson };
-}
-
-export async function writeSkillTree(
- fs: FileReader & FileWriter,
- pluginName: string,
- pluginSrc: string,
- pluginOut: string,
- transform?: SkillContentTransform
-): Promise {
- const skillsSrc = join(pluginSrc, "skills");
- if (!(await fs.fileExists(skillsSrc))) return 0;
- const files = await fs.listFilesRecursive(skillsSrc);
- let count = 0;
- for (const absPath of files) {
- count += await writeSkillFile(fs, pluginName, absPath, skillsSrc, pluginOut, transform);
- }
- return count;
-}
-
-async function writeSkillFile(
- fs: FileReader & FileWriter,
- pluginName: string,
- absPath: string,
- skillsSrc: string,
- pluginOut: string,
- transform?: SkillContentTransform
-): Promise {
- const relPath = relative(skillsSrc, absPath).replace(/\\/g, "/");
- const destPath = join(pluginOut, "skills", relPath);
- const content = await fs.readFile(absPath);
- if (!absPath.endsWith(".md")) {
- await fs.writeFile(destPath, content);
- return 1;
- }
-
- assertNoToolsPlaceholder(content, pluginName, relPath);
- const rewritten = rewriteRelativeLinks(content, {
- currentFilePluginRelative: `skills/${relPath}`,
- });
- let output = rewritten;
- if (transform && basename(absPath) === PLUGIN_SKILL_ENTRY_FILE) {
- output = transform(rewritten, pluginName, PLUGIN_SKILL_ENTRY_FILE);
- }
- await fs.writeFile(destPath, output);
- return 1;
-}
-
-export async function resolveVersion(
- fs: FileReader,
- name: string,
- srcEntry: { version?: string } | undefined,
- outDir: string,
- outputManifestRelative: string
-): Promise {
- if (srcEntry?.version) return srcEntry.version;
- const manifestPath = join(outDir, "plugins", name, outputManifestRelative);
- const raw = await fs.readFile(manifestPath);
- const manifest = JSON.parse(raw) as Record;
- if (typeof manifest.version === "string") return manifest.version;
- throw new InvalidSourceMarketplaceError(
- `plugin '${name}' has no version in marketplace entry or plugin.json`
- );
-}
-
-export interface SynthesizeClaudeStyleManifestOpts {
- /** Output manifest subdirectory name (e.g. ".claude-plugin" or ".cursor-plugin"). Reserved for caller/future divergence. */
- readonly manifestDir: string;
- /** When true, include `agents` as a list of `./agents/*.md` file paths if agents are present. */
- readonly agentsField: boolean;
-}
-
-/**
- * Synthesize a Claude-style plugin manifest shared by claude + cursor + copilot strategies.
- * Key insertion order: name, description, version, author, homepage, repository, license,
- * keywords, agents (conditional), skills (conditional), hooks (conditional), mcpServers (conditional).
- */
-export function synthesizeClaudeStyleManifest(
- source: Record,
- presence: PluginPresenceFlags,
- opts: SynthesizeClaudeStyleManifestOpts
-): Record {
- const manifest: Record = {};
- if (typeof source.name === "string") manifest.name = source.name;
- if (typeof source.description === "string") manifest.description = source.description;
- if (typeof source.version === "string") manifest.version = source.version;
- if (typeof source.author === "string" || typeof source.author === "object")
- manifest.author = source.author;
- if (typeof source.homepage === "string") manifest.homepage = source.homepage;
- if (typeof source.repository === "string") manifest.repository = source.repository;
- if (typeof source.license === "string") manifest.license = source.license;
- if (Array.isArray(source.keywords)) manifest.keywords = source.keywords;
- if (opts.agentsField && presence.agentsList.length > 0)
- manifest.agents = presence.agentsList.map((n) => `./agents/${n}`);
- if (presence.skillsList.length > 0)
- manifest.skills = presence.skillsList.map((n) => `./skills/${n}`);
- if (presence.hasHooksJson) manifest.hooks = "./hooks/hooks.json";
- if (presence.hasMcpJson) manifest.mcpServers = "./.mcp.json";
- return manifest;
-}
-
-/**
- * Build a Claude-style marketplace catalog object shared by claude + cursor + codex strategies.
- */
-export function buildClaudeStyleMarketplace(
- source: { name: string; version?: string; description?: string; owner?: unknown },
- pluginEntries: readonly Record[]
-): Record {
- const obj: Record = { name: source.name };
- if (typeof source.version === "string") obj.version = source.version;
- if (typeof source.description === "string") obj.description = source.description;
- if (source.owner !== undefined) obj.owner = source.owner;
- obj.plugins = pluginEntries;
- return obj;
-}
-
-export function buildClaudeStyleCatalogEntry(
- name: string,
- description: string,
- version: string,
- srcEntry: Record | undefined
-): Record {
- const entry: Record = {
- name,
- source: `./plugins/${name}`,
- description,
- version,
- };
- if (typeof srcEntry?.strict === "boolean") entry.strict = srcEntry.strict;
- if (typeof srcEntry?.recommended === "boolean") entry.recommended = srcEntry.recommended;
- return entry;
-}
-
-// ── Codex-native marketplace catalog (for `codex plugin marketplace add`) ──────
-// Shape verified 2026-07-05 against https://github.com/openai/plugins
-// .agents/plugins/marketplace.json and https://developers.openai.com/codex/plugins/build.
-
-/** Default category when the source marketplace entry does not specify one. */
-export const CODEX_DEFAULT_CATEGORY = "Developer Tools";
-/**
- * Default per-plugin auth policy. AIDD plugins bundle skills/agents/hooks with no
- * external OAuth, so auth is deferred to first use rather than forced at install.
- */
-export const CODEX_DEFAULT_AUTHENTICATION = "ON_USE";
-const CODEX_INSTALLATION_AVAILABLE = "AVAILABLE";
-
-/**
- * Build a Codex marketplace catalog: `{ name, interface: { displayName }, plugins }`.
- * `displayName` falls back to the marketplace name when the source omits it.
- */
-export function buildCodexMarketplace(
- source: { name: string; displayName?: string },
- pluginEntries: readonly Record[]
-): Record {
- const displayName = typeof source.displayName === "string" ? source.displayName : source.name;
- return { name: source.name, interface: { displayName }, plugins: pluginEntries };
-}
-
-/**
- * Build a single Codex marketplace entry. `installation`/`authentication`/`category`
- * are required per the plugin-creator spec; `authentication` and `category` accept a
- * source-entry override, else fall back to the AIDD-shaped defaults.
- */
-export function buildCodexMarketplaceEntry(
- name: string,
- srcEntry: Record | undefined
-): Record {
- const authentication =
- typeof srcEntry?.authentication === "string"
- ? srcEntry.authentication
- : CODEX_DEFAULT_AUTHENTICATION;
- const category =
- typeof srcEntry?.category === "string" ? srcEntry.category : CODEX_DEFAULT_CATEGORY;
- return {
- name,
- source: { source: "local", path: `./plugins/${name}` },
- policy: { installation: CODEX_INSTALLATION_AVAILABLE, authentication },
- category,
- };
-}
-
-export async function resolveDescription(
- fs: FileReader,
- name: string,
- srcEntry: { description?: string } | undefined,
- outDir: string,
- outputManifestRelative: string
-): Promise {
- if (srcEntry?.description) return srcEntry.description;
- const manifestPath = join(outDir, "plugins", name, outputManifestRelative);
- const raw = await fs.readFile(manifestPath);
- const manifest = JSON.parse(raw) as Record;
- if (typeof manifest.description === "string" && manifest.description.length > 0) {
- return manifest.description;
- }
- throw new InvalidSourceMarketplaceError(
- `plugin '${name}' has no description in marketplace entry or plugin.json`
- );
-}
diff --git a/cli/src/application/use-cases/framework/strategies/plugin-source-tree-reader.ts b/cli/src/application/use-cases/framework/strategies/plugin-source-tree-reader.ts
new file mode 100644
index 000000000..d127d7bf9
--- /dev/null
+++ b/cli/src/application/use-cases/framework/strategies/plugin-source-tree-reader.ts
@@ -0,0 +1,96 @@
+import { join, relative } from "node:path";
+import { InvalidSourceMarketplaceError } from "../../../../domain/errors.js";
+import {
+ PLUGIN_AGENT_INPUT_EXT,
+ PLUGIN_HOOKS_RELATIVE,
+ PLUGIN_MCP_RELATIVE,
+} from "../../../../domain/models/framework-build.js";
+import type { FileReader } from "../../../../domain/ports/file-reader.js";
+
+export interface PluginPresenceFlags {
+ readonly hasAgents: boolean;
+ /** Agent markdown files relative to the plugin's `agents/` dir (e.g. "planner.md"), sorted. */
+ readonly agentsList: readonly string[];
+ readonly skillsList: readonly string[];
+ readonly hasHooksJson: boolean;
+ readonly hasMcpJson: boolean;
+}
+
+export async function listAgentFiles(
+ fs: FileReader,
+ agentsDir: string
+): Promise {
+ if (!(await fs.fileExists(agentsDir))) return [];
+ const files = await fs.listFilesRecursive(agentsDir);
+ return files
+ .filter((f) => f.endsWith(PLUGIN_AGENT_INPUT_EXT))
+ .map((f) => relative(agentsDir, f).replace(/\\/g, "/"))
+ .sort();
+}
+
+export async function listSkillNames(
+ fs: FileReader,
+ pluginSrc: string
+): Promise {
+ const skillsDir = join(pluginSrc, "skills");
+ if (!(await fs.fileExists(skillsDir))) return [];
+ const files = await fs.listFilesRecursive(skillsDir);
+ const names = new Set();
+ for (const f of files) {
+ if (!f.endsWith("/SKILL.md") && !f.endsWith("\\SKILL.md") && !f.endsWith("SKILL.md")) {
+ continue;
+ }
+ const rel = relative(skillsDir, f);
+ const parts = rel.replace(/\\/g, "/").split("/");
+ if (parts.length >= 2) names.add(parts[0]);
+ }
+ return [...names].sort();
+}
+
+export async function detectPluginPresenceFlags(
+ fs: FileReader,
+ pluginSrc: string
+): Promise {
+ const agentsDir = join(pluginSrc, "agents");
+ const agentsList = await listAgentFiles(fs, agentsDir);
+ const skillsList = await listSkillNames(fs, pluginSrc);
+ const hasHooksJson = await fs.fileExists(join(pluginSrc, PLUGIN_HOOKS_RELATIVE));
+ const hasMcpJson = await fs.fileExists(join(pluginSrc, PLUGIN_MCP_RELATIVE));
+ return { hasAgents: agentsList.length > 0, agentsList, skillsList, hasHooksJson, hasMcpJson };
+}
+
+export async function resolveVersion(
+ fs: FileReader,
+ name: string,
+ srcEntry: { version?: string } | undefined,
+ outDir: string,
+ outputManifestRelative: string
+): Promise {
+ if (srcEntry?.version) return srcEntry.version;
+ const manifestPath = join(outDir, "plugins", name, outputManifestRelative);
+ const raw = await fs.readFile(manifestPath);
+ const manifest = JSON.parse(raw) as Record;
+ if (typeof manifest.version === "string") return manifest.version;
+ throw new InvalidSourceMarketplaceError(
+ `plugin '${name}' has no version in marketplace entry or plugin.json`
+ );
+}
+
+export async function resolveDescription(
+ fs: FileReader,
+ name: string,
+ srcEntry: { description?: string } | undefined,
+ outDir: string,
+ outputManifestRelative: string
+): Promise {
+ if (srcEntry?.description) return srcEntry.description;
+ const manifestPath = join(outDir, "plugins", name, outputManifestRelative);
+ const raw = await fs.readFile(manifestPath);
+ const manifest = JSON.parse(raw) as Record;
+ if (typeof manifest.description === "string" && manifest.description.length > 0) {
+ return manifest.description;
+ }
+ throw new InvalidSourceMarketplaceError(
+ `plugin '${name}' has no description in marketplace entry or plugin.json`
+ );
+}
diff --git a/cli/src/application/use-cases/framework/strategies/tool-contracts.ts b/cli/src/application/use-cases/framework/strategies/tool-contracts.ts
index dfbc49ddf..4a44ee3cc 100644
--- a/cli/src/application/use-cases/framework/strategies/tool-contracts.ts
+++ b/cli/src/application/use-cases/framework/strategies/tool-contracts.ts
@@ -35,6 +35,7 @@ import {
mergeCursorFlatHooks,
} from "../../../../domain/formats/flat-hooks-merge.js";
import {
+ flatHooksSharedDirPath,
flatMcpKeyPrefix,
genericFlatAgentPath,
genericFlatHooksFile,
@@ -57,21 +58,27 @@ import {
} from "../../../../domain/models/framework-build.js";
import type { FileReader } from "../../../../domain/ports/file-reader.js";
import type { FileWriter } from "../../../../domain/ports/file-writer.js";
+import { claude } from "../../../../domain/tools/ai/claude.js";
import {
+ codex,
mergeCodexConfigToml,
stripCodexSkillFrontmatter,
} from "../../../../domain/tools/ai/codex.js";
-import { transformMcpToOpencode } from "../../../../domain/tools/ai/opencode.js";
-import type { PluginPresence, ToolBuildContract } from "../../../../domain/tools/build-contract.js";
+import { copilot } from "../../../../domain/tools/ai/copilot.js";
+import { cursor } from "../../../../domain/tools/ai/cursor.js";
+import { opencode, transformMcpToOpencode } from "../../../../domain/tools/ai/opencode.js";
+import type {
+ ArtifactContract,
+ PluginPresence,
+ ToolBuildContract,
+} from "../../../../domain/tools/build-contract.js";
+import { buildCodexMarketplace, buildCodexMarketplaceEntry } from "./codex-marketplace-catalog.js";
import {
- buildClaudeStyleCatalogEntry,
- buildClaudeStyleMarketplace,
- buildCodexMarketplace,
- buildCodexMarketplaceEntry,
- resolveDescription,
- resolveVersion,
- synthesizeClaudeStyleManifest,
-} from "./marketplace-strategy-helpers.js";
+ buildDefaultCatalogEntry,
+ buildDefaultMarketplace,
+ synthesizeDefaultPluginManifest,
+} from "./default-plugin-catalog.js";
+import { resolveDescription, resolveVersion } from "./plugin-source-tree-reader.js";
type FsType = FileReader & FileWriter;
type SrcEntry =
@@ -99,7 +106,7 @@ function transformCursorAgent(content: string, _plugin: string, outName: string)
// ── Shared catalog builders ────────────────────────────────────────────────────
-async function buildClaudeStyleEntry(
+async function buildDefaultEntry(
name: string,
outDir: string,
srcEntry: SrcEntry,
@@ -109,7 +116,7 @@ async function buildClaudeStyleEntry(
const args = [fs, name, srcEntry, outDir, manifestRelative] as const;
const version = await resolveVersion(...args);
const description = await resolveDescription(...args);
- return buildClaudeStyleCatalogEntry(
+ return buildDefaultCatalogEntry(
name,
description,
version,
@@ -122,17 +129,15 @@ async function buildClaudeStyleEntry(
export function buildClaudeContract(): ToolBuildContract {
const manifestRelative = OUTPUT_CLAUDE_MANIFEST_RELATIVE;
const marketplaceRelative = OUTPUT_CLAUDE_MARKETPLACE_RELATIVE;
- // Split literal to avoid biome's noTemplateCurlyInString warning.
- const claudeToken = "$" + "{CLAUDE_PLUGIN_ROOT}";
return {
manifestDir: ".claude-plugin",
marketplaceRelative,
- pluginRootToken: claudeToken,
+ pluginRootToken: claude.capabilities.plugins.pluginRootToken,
manifestFileRelative: manifestRelative,
synthesizeManifest: (source, presence) =>
- synthesizeClaudeStyleManifest(source, presence, {
- manifestDir: ".claude-plugin",
+ synthesizeDefaultPluginManifest(source, presence, {
agentsField: true,
+ hooksField: false,
}),
manifestSchemaName: "plugin-manifest",
artifacts: {
@@ -161,15 +166,15 @@ export function buildClaudeContract(): ToolBuildContract {
commands: { supported: false },
},
buildMarketplaceCatalog: async (source, entries, _fs) => ({
- catalog: buildClaudeStyleMarketplace(
- source as Parameters[0],
+ catalog: buildDefaultMarketplace(
+ source as Parameters[0],
entries
),
schemaName: "claude-marketplace",
destRelPath: marketplaceRelative,
}),
buildMarketplaceEntry: async (name, _src, outDir, srcEntry, fs) =>
- buildClaudeStyleEntry(name, outDir, srcEntry, manifestRelative, fs),
+ buildDefaultEntry(name, outDir, srcEntry, manifestRelative, fs),
};
}
@@ -178,17 +183,15 @@ export function buildClaudeContract(): ToolBuildContract {
export function buildCursorContract(): ToolBuildContract {
const manifestRelative = OUTPUT_CURSOR_MANIFEST_RELATIVE;
const marketplaceRelative = OUTPUT_CURSOR_MARKETPLACE_RELATIVE;
- // Split literal to avoid biome's noTemplateCurlyInString warning.
- const cursorToken = "$" + "{CURSOR_PLUGIN_ROOT}";
return {
manifestDir: ".cursor-plugin",
marketplaceRelative,
- pluginRootToken: cursorToken,
+ pluginRootToken: cursor.capabilities.plugins.pluginRootToken,
manifestFileRelative: manifestRelative,
synthesizeManifest: (source, presence) =>
- synthesizeClaudeStyleManifest(source, presence, {
- manifestDir: ".cursor-plugin",
+ synthesizeDefaultPluginManifest(source, presence, {
agentsField: true,
+ hooksField: true,
}),
manifestSchemaName: "plugin-manifest",
artifacts: {
@@ -217,15 +220,15 @@ export function buildCursorContract(): ToolBuildContract {
commands: { supported: false },
},
buildMarketplaceCatalog: async (source, entries, _fs) => ({
- catalog: buildClaudeStyleMarketplace(
- source as Parameters[0],
+ catalog: buildDefaultMarketplace(
+ source as Parameters[0],
entries
),
schemaName: "claude-marketplace",
destRelPath: marketplaceRelative,
}),
buildMarketplaceEntry: async (name, _src, outDir, srcEntry, fs) =>
- buildClaudeStyleEntry(name, outDir, srcEntry, manifestRelative, fs),
+ buildDefaultEntry(name, outDir, srcEntry, manifestRelative, fs),
};
}
@@ -234,17 +237,15 @@ export function buildCursorContract(): ToolBuildContract {
export function buildCopilotMarketplaceContract(): ToolBuildContract {
const manifestRelative = OUTPUT_PLUGIN_MANIFEST_RELATIVE;
const marketplaceRelative = OUTPUT_MARKETPLACE_RELATIVE;
- // Split literal to avoid biome's noTemplateCurlyInString warning.
- const copilotToken = "$" + "{PLUGIN_ROOT}";
return {
manifestDir: ".plugin",
marketplaceRelative,
- pluginRootToken: copilotToken,
+ pluginRootToken: copilot.capabilities.plugins.pluginRootToken,
manifestFileRelative: manifestRelative,
synthesizeManifest: (source, presence) =>
- synthesizeClaudeStyleManifest(source, presence, {
- manifestDir: ".plugin",
+ synthesizeDefaultPluginManifest(source, presence, {
agentsField: true,
+ hooksField: true,
}),
manifestSchemaName: null, // Copilot does not use AJV for the plugin manifest
artifacts: {
@@ -342,12 +343,10 @@ function transformCodexSkill(content: string): string {
export function buildCodexContract(): ToolBuildContract {
const manifestRelative = OUTPUT_CODEX_MANIFEST_RELATIVE;
const marketplaceRelative = OUTPUT_CODEX_MARKETPLACE_RELATIVE;
- // Split literal to avoid biome's noTemplateCurlyInString warning.
- const codexToken = "$" + "{PLUGIN_ROOT}";
return {
manifestDir: ".codex-plugin",
marketplaceRelative,
- pluginRootToken: codexToken,
+ pluginRootToken: codex.capabilities.plugins.pluginRootToken,
manifestFileRelative: manifestRelative,
synthesizeManifest: buildCodexManifest,
manifestSchemaName: "codex-plugin-manifest",
@@ -738,6 +737,14 @@ function opencodeFlatResolveTarget(plugin: string, rel: string): string {
return rel;
}
+// OpenCode's loader scans one directory non-recursively (flatHooksDir), so a hook script
+// lands there directly — no plugin-name segment, the same shape `translateFlat` delivers
+// for the install route (plugin-content-translator.ts's flatHooksFiles, via the same
+// flatHooksSharedDirPath).
+function makeOpencodeFlatHooksPath(flatHooksDir: string): (plugin: string, rel: string) => string {
+ return (_plugin, rel) => flatHooksSharedDirPath(flatHooksDir, rel);
+}
+
function transformOpencodeFlatAgent(content: string, plugin: string, outName: string): string {
const { frontmatter, body } = parseFrontmatter(content);
const flatRelPath = opencodeFlatAgentPath(plugin, `agents/${outName}`);
@@ -780,6 +787,20 @@ async function collectOpencodeMcp(
return incoming;
}
+// Delivers what `aidd plugin install --tool opencode` delivers: `flatHooksDir` is the
+// tool's own declaration (opencode.ts), read here rather than restated, so the two
+// routes cannot fall out of sync the way they did before this fix.
+function buildOpencodeFlatHooksArtifact(): ArtifactContract {
+ const { flatHooksDir } = opencode.capabilities.plugins;
+ if (flatHooksDir === null) return { supported: false };
+ return {
+ supported: true,
+ source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" },
+ path: makeOpencodeFlatHooksPath(flatHooksDir),
+ skipHooksJson: true,
+ };
+}
+
export function buildOpencodeFlatContract(): ToolBuildContract {
return {
manifestDir: null,
@@ -801,7 +822,7 @@ export function buildOpencodeFlatContract(): ToolBuildContract {
transform: transformOpencodeFlatAgent,
},
mcp: { supported: false }, // handled by emitConfigArtifact (opencode.json mcp)
- hooks: { supported: false }, // opencode has no HasHooks capability
+ hooks: buildOpencodeFlatHooksArtifact(),
rules: { supported: false },
commands: { supported: false },
},
diff --git a/cli/src/application/use-cases/framework/strategies/write-skill-tree.ts b/cli/src/application/use-cases/framework/strategies/write-skill-tree.ts
new file mode 100644
index 000000000..92ceaf9df
--- /dev/null
+++ b/cli/src/application/use-cases/framework/strategies/write-skill-tree.ts
@@ -0,0 +1,51 @@
+import { basename, join, relative } from "node:path";
+import { rewriteRelativeLinks } from "../../../../domain/formats/relative-link-rewrite.js";
+import { PLUGIN_SKILL_ENTRY_FILE } from "../../../../domain/models/framework-build.js";
+import type { FileReader } from "../../../../domain/ports/file-reader.js";
+import type { FileWriter } from "../../../../domain/ports/file-writer.js";
+import { assertNoToolsPlaceholder } from "../assert-no-tools-placeholder.js";
+
+type SkillContentTransform = (content: string, plugin: string, basename: string) => string;
+
+export async function writeSkillTree(
+ fs: FileReader & FileWriter,
+ pluginName: string,
+ pluginSrc: string,
+ pluginOut: string,
+ transform?: SkillContentTransform
+): Promise {
+ const skillsSrc = join(pluginSrc, "skills");
+ if (!(await fs.fileExists(skillsSrc))) return 0;
+ const files = await fs.listFilesRecursive(skillsSrc);
+ let count = 0;
+ for (const absPath of files) {
+ count += await writeSkillFile(fs, pluginName, absPath, skillsSrc, pluginOut, transform);
+ }
+ return count;
+}
+
+async function writeSkillFile(
+ fs: FileReader & FileWriter,
+ pluginName: string,
+ absPath: string,
+ skillsSrc: string,
+ pluginOut: string,
+ transform?: SkillContentTransform
+): Promise {
+ const relPath = relative(skillsSrc, absPath).replace(/\\/g, "/");
+ const destPath = join(pluginOut, "skills", relPath);
+ const content = await fs.readFile(absPath);
+ if (absPath.endsWith(".md")) {
+ assertNoToolsPlaceholder(content, pluginName, relPath);
+ const currentFilePluginRelative = `skills/${relPath}`;
+ const rewritten = rewriteRelativeLinks(content, { currentFilePluginRelative });
+ const isEntry = transform !== undefined && basename(absPath) === PLUGIN_SKILL_ENTRY_FILE;
+ await fs.writeFile(
+ destPath,
+ isEntry ? transform(rewritten, pluginName, PLUGIN_SKILL_ENTRY_FILE) : rewritten
+ );
+ } else {
+ await fs.writeFile(destPath, content);
+ }
+ return 1;
+}
diff --git a/cli/src/application/use-cases/plugin/plugin-add-use-case.ts b/cli/src/application/use-cases/plugin/plugin-add-use-case.ts
index cca7d9899..ed2454dbb 100644
--- a/cli/src/application/use-cases/plugin/plugin-add-use-case.ts
+++ b/cli/src/application/use-cases/plugin/plugin-add-use-case.ts
@@ -5,11 +5,13 @@ import {
MissingPluginMetadataError,
VersionMismatchError,
} from "../../../domain/errors.js";
+import type { InstallationFile } from "../../../domain/models/file.js";
import type { Manifest } from "../../../domain/models/manifest.js";
import { DOCS_DIR, PLUGIN_CACHE_SUBDIR } from "../../../domain/models/paths.js";
import { Plugin } from "../../../domain/models/plugin.js";
import { PluginContentTranslator } from "../../../domain/models/plugin-content-translator.js";
import type { PluginDistribution } from "../../../domain/models/plugin-distribution.js";
+import type { ReadonlyNoticeList } from "../../../domain/models/plugin-install-notice.js";
import type { PluginSource } from "../../../domain/models/plugin-source.js";
import type { ReadonlySkipList } from "../../../domain/models/plugin-translation-skip.js";
import type { AiToolId } from "../../../domain/models/tool-ids.js";
@@ -23,7 +25,8 @@ import type { PluginDistributionReader } from "../../../domain/ports/plugin-dist
import type { PluginFetcher } from "../../../domain/ports/plugin-fetcher.js";
import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js";
import type { EnsureBuiltMarketplaceUseCase } from "../shared/ensure-built-marketplace-use-case.js";
-import { loadPluginManifest, resolvePluginToolIds, writePluginFiles } from "./plugin-helpers.js";
+import { loadPluginManifest, writePluginFiles } from "./plugin-file-sync.js";
+import { resolvePluginToolIds } from "./plugin-target-resolution.js";
import type { PluginTranslator } from "./translator/plugin-translator.js";
import { resolvePluginTranslator } from "./translator/resolve-plugin-translator.js";
@@ -191,9 +194,10 @@ export class PluginAddUseCase {
prevMcpMap: Map>
): Promise {
const allSkipped: ReadonlySkipList[] = [];
+ const allNotices: ReadonlyNoticeList[] = [];
for (const toolId of toolIds) {
const prev = prevMcpMap.get(toolId) ?? new Map();
- const { skipped } = await this.addPluginForTool(
+ const { skipped, notices } = await this.addPluginForTool(
dist,
toolId,
source,
@@ -204,8 +208,10 @@ export class PluginAddUseCase {
prev
);
allSkipped.push(skipped);
+ allNotices.push(notices);
}
this.emitSkipWarnings(allSkipped.flat());
+ this.emitInstallNotices(allNotices.flat());
}
private collectPreviousMcpEntries(
@@ -255,12 +261,12 @@ export class PluginAddUseCase {
marketplace: string | undefined,
docsDir: string,
previousMcpEntries: ReadonlyMap = new Map()
- ): Promise<{ skipped: ReadonlySkipList }> {
+ ): Promise<{ skipped: ReadonlySkipList; notices: ReadonlyNoticeList }> {
const toolConfig = getToolConfig(toolId);
- if (!isAiTool(toolConfig)) return { skipped: [] };
+ if (!isAiTool(toolConfig)) return { skipped: [], notices: [] };
const adapter = this.resolveAdapter(toolConfig);
if (adapter?.mode === "flat") {
- return adapter.addPlugin(
+ const result = await adapter.addPlugin(
dist,
toolId,
source,
@@ -270,20 +276,65 @@ export class PluginAddUseCase {
docsDir,
previousMcpEntries
);
+ return { ...result, notices: [] };
}
- const { files, componentPaths, skipped } = new PluginContentTranslator(
- this.hasher
- ).translateWithComponentPaths(dist, toolConfig, docsDir);
- if (files.length === 0) return { skipped };
+ const translated = new PluginContentTranslator(this.hasher).translateWithComponentPaths(
+ dist,
+ toolConfig,
+ docsDir
+ );
+ return this.materializeNativePlugin(
+ dist,
+ toolId,
+ source,
+ projectRoot,
+ manifest,
+ marketplace,
+ docsDir,
+ adapter,
+ translated
+ );
+ }
+
+ // `notices` survives every branch below, including the marketplace one that discards its
+ // own `translated.skipped` in favor of the adapter's — a delivered hook's trust notice is
+ // a fact about the tool, not about which materialization route happened to run.
+ private async materializeNativePlugin(
+ dist: PluginDistribution,
+ toolId: AiToolId,
+ source: PluginSource,
+ projectRoot: string,
+ manifest: Manifest,
+ marketplace: string | undefined,
+ docsDir: string,
+ adapter: PluginTranslator | null,
+ translated: {
+ files: InstallationFile[];
+ componentPaths: ReadonlyMap;
+ skipped: ReadonlySkipList;
+ notices: ReadonlyNoticeList;
+ }
+ ): Promise<{ skipped: ReadonlySkipList; notices: ReadonlyNoticeList }> {
+ const { files, componentPaths, skipped, notices } = translated;
+ if (files.length === 0) return { skipped, notices };
if (adapter?.mode === "marketplace" && source.kind === "local" && marketplace !== undefined) {
- return adapter.addPlugin(dist, toolId, source, projectRoot, manifest, marketplace, docsDir);
+ const result = await adapter.addPlugin(
+ dist,
+ toolId,
+ source,
+ projectRoot,
+ manifest,
+ marketplace,
+ docsDir
+ );
+ return { ...result, notices };
}
await writePluginFiles(files, projectRoot, this.fs);
manifest.addPlugin(
toolId,
Plugin.fromDistribution(dist, source, files, componentPaths, marketplace)
);
- return { skipped };
+ return { skipped, notices };
}
private emitSkipWarnings(skipped: ReadonlySkipList): void {
@@ -294,6 +345,12 @@ export class PluginAddUseCase {
}
}
+ private emitInstallNotices(notices: ReadonlyNoticeList): void {
+ for (const entry of notices) {
+ this.logger.info(`Plugin "${entry.pluginName}" (${entry.toolId}): ${entry.message}`);
+ }
+ }
+
private resolveAdapter(toolConfig: ReturnType): PluginTranslator | null {
if (toolConfig === undefined) return null;
return resolvePluginTranslator(toolConfig, {
diff --git a/cli/src/application/use-cases/plugin/plugin-helpers.ts b/cli/src/application/use-cases/plugin/plugin-file-sync.ts
similarity index 67%
rename from cli/src/application/use-cases/plugin/plugin-helpers.ts
rename to cli/src/application/use-cases/plugin/plugin-file-sync.ts
index 4213c2108..90d00e170 100644
--- a/cli/src/application/use-cases/plugin/plugin-helpers.ts
+++ b/cli/src/application/use-cases/plugin/plugin-file-sync.ts
@@ -1,56 +1,16 @@
import { join } from "node:path";
-import { McpCapability } from "../../../domain/capabilities/mcp-capability.js";
-import type { PluginsCapability } from "../../../domain/capabilities/plugins-capability.js";
import type { InstallationFile } from "../../../domain/models/file.js";
import type { Manifest } from "../../../domain/models/manifest.js";
import type { Plugin } from "../../../domain/models/plugin.js";
import type { PluginDistribution } from "../../../domain/models/plugin-distribution.js";
import type { AiToolId } from "../../../domain/models/tool-ids.js";
-import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js";
import type { FileReader } from "../../../domain/ports/file-reader.js";
import type { FileWriter } from "../../../domain/ports/file-writer.js";
import type { Hasher } from "../../../domain/ports/hasher.js";
import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
-import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js";
import { NoManifestError } from "../../errors.js";
import type { PluginTranslator } from "./translator/plugin-translator.js";
-export function resolvePluginToolIds(toolIds: AiToolId[] | "all", manifest: Manifest): AiToolId[] {
- if (toolIds !== "all") return toolIds;
- return AI_TOOL_IDS.filter((id) => manifest.hasTool(id)) as AiToolId[];
-}
-
-/** The base directory a plugin's files live under: `projectRoot` for project-scope
- * plugins, the home-relative dir `PluginsCapability` resolves for user-scope ones. */
-export function resolvePluginBaseDirForCapability(
- plugins: PluginsCapability,
- projectRoot: string,
- homedir: () => string
-): string {
- return plugins.resolvePluginsBaseDir(projectRoot, homedir());
-}
-
-export function resolvePluginBaseDir(
- toolId: AiToolId,
- projectRoot: string,
- homedir: () => string
-): string {
- const toolConfig = getToolConfig(toolId);
- if (!isAiTool(toolConfig)) return projectRoot;
- const caps = toolConfig.capabilities as Record;
- if (!("plugins" in caps)) return projectRoot;
- return resolvePluginBaseDirForCapability(caps.plugins as PluginsCapability, projectRoot, homedir);
-}
-
-export function qualifiesForOpencodeMcpMerge(caps: Record): boolean {
- if (!("mcp" in caps)) return false;
- const mcp = caps.mcp;
- if (!(mcp instanceof McpCapability)) return false;
- if (mcp.params.mergeStrategy !== "framework-prime") return false;
- const plugins = caps.plugins as PluginsCapability;
- return plugins.mode === "flat";
-}
-
export async function loadPluginManifest(manifestRepo: ManifestRepository): Promise {
const manifest = await manifestRepo.load();
if (manifest === null) throw new NoManifestError();
diff --git a/cli/src/application/use-cases/plugin/plugin-install-use-case.ts b/cli/src/application/use-cases/plugin/plugin-install-use-case.ts
index 8f1ada1f2..c9f019d56 100644
--- a/cli/src/application/use-cases/plugin/plugin-install-use-case.ts
+++ b/cli/src/application/use-cases/plugin/plugin-install-use-case.ts
@@ -1,3 +1,4 @@
+import { isAbsolute } from "node:path";
import { InteractiveOnlyError, TrustDeniedError } from "../../../domain/errors.js";
import {
assertToolSupportsScope,
@@ -64,7 +65,10 @@ export class PluginInstallUseCase {
}
private isSourceArg(arg: string): boolean {
- return arg.includes("://") || arg.startsWith("/") || arg.startsWith("./");
+ // `isAbsolute` also catches a Windows-rooted path (`C:\...`, `\\server\share`), which
+ // `startsWith("/")` never does - without it, a local Windows source falls through to
+ // the marketplace branch below and is looked up as a package name instead (#707).
+ return arg.includes("://") || arg.startsWith("./") || isAbsolute(arg);
}
private async executeNoArg(options: PluginInstallOptions): Promise {
diff --git a/cli/src/application/use-cases/plugin/plugin-list-use-case.ts b/cli/src/application/use-cases/plugin/plugin-list-use-case.ts
index 3e1c20bd1..0ef2d38de 100644
--- a/cli/src/application/use-cases/plugin/plugin-list-use-case.ts
+++ b/cli/src/application/use-cases/plugin/plugin-list-use-case.ts
@@ -2,7 +2,8 @@ import type { Manifest } from "../../../domain/models/manifest.js";
import type { Plugin } from "../../../domain/models/plugin.js";
import type { AiToolId } from "../../../domain/models/tool-ids.js";
import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
-import { loadPluginManifest, resolvePluginToolIds } from "./plugin-helpers.js";
+import { loadPluginManifest } from "./plugin-file-sync.js";
+import { resolvePluginToolIds } from "./plugin-target-resolution.js";
export interface PluginListOptions {
toolIds: AiToolId[] | "all";
diff --git a/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts b/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts
index 82b117a55..62aa7267e 100644
--- a/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts
+++ b/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts
@@ -2,6 +2,10 @@ import { homedir as nodeHomedir } from "node:os";
import { dirname, join } from "node:path";
import type { McpCapability } from "../../../domain/capabilities/mcp-capability.js";
import { PluginNotFoundError } from "../../../domain/errors.js";
+import {
+ cursorProjectHooksScriptDir,
+ unmergeCursorProjectHooksJson,
+} from "../../../domain/formats/cursor-hooks-project-merge.js";
import { unmergeOpencodeMcp } from "../../../domain/formats/opencode-mcp-merge.js";
import type { Manifest } from "../../../domain/models/manifest.js";
import type { Plugin } from "../../../domain/models/plugin.js";
@@ -10,12 +14,13 @@ import type { FileReader } from "../../../domain/ports/file-reader.js";
import type { FileWriter } from "../../../domain/ports/file-writer.js";
import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js";
+import { loadPluginManifest } from "./plugin-file-sync.js";
import {
- loadPluginManifest,
- qualifiesForOpencodeMcpMerge,
+ isFrameworkPrimeFlatMcp,
resolvePluginBaseDir,
resolvePluginToolIds,
-} from "./plugin-helpers.js";
+} from "./plugin-target-resolution.js";
+import { resolvePluginsCapability } from "./translator/project-hooks-materializer.js";
export interface PluginRemoveOptions {
pluginName: string;
@@ -52,12 +57,33 @@ export class PluginRemoveUseCase {
const baseDir = resolvePluginBaseDir(toolId, projectRoot, nodeHomedir);
await this.deletePluginFiles(plugin.files, baseDir);
await this.removeMcpEntries(plugin, toolId, projectRoot);
+ await this.removeProjectHooks(pluginName, toolId, projectRoot);
manifest.removePlugin(toolId, pluginName);
removed = true;
}
return removed;
}
+ // The install-time counterpart of ProjectHooksMaterializer: a plugin whose hooks
+ // were merged into the project's own .cursor/hooks.json (never tracked in
+ // Plugin.files — see mode-b-flat-materialization-translator.ts) needs its own
+ // unmerge, not a baseDir-relative file delete. Both destinations are recomputed
+ // from pluginName alone, exactly as install computed them — no extra state to keep
+ // in sync.
+ private async removeProjectHooks(
+ pluginName: string,
+ toolId: AiToolId,
+ projectRoot: string
+ ): Promise {
+ if (resolvePluginsCapability(toolId)?.hooksDestination !== "project") return;
+ const hooksPath = join(projectRoot, ".cursor", "hooks.json");
+ const existing = await this.readExistingJson(hooksPath);
+ if (existing !== null) {
+ await this.fs.writeFile(hooksPath, unmergeCursorProjectHooksJson(existing, pluginName));
+ }
+ await this.fs.deleteDirectory(join(projectRoot, cursorProjectHooksScriptDir(pluginName)));
+ }
+
private async removeMcpEntries(
plugin: Plugin,
toolId: AiToolId,
@@ -67,7 +93,7 @@ export class PluginRemoveUseCase {
const toolConfig = getToolConfig(toolId);
if (!isAiTool(toolConfig)) return;
const caps = toolConfig.capabilities as Record;
- if (!qualifiesForOpencodeMcpMerge(caps)) return;
+ if (!isFrameworkPrimeFlatMcp(caps)) return;
const mcpCap = caps.mcp as McpCapability;
const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs);
const outputPath = join(projectRoot, outputRelPath);
diff --git a/cli/src/application/use-cases/plugin/plugin-target-resolution.ts b/cli/src/application/use-cases/plugin/plugin-target-resolution.ts
new file mode 100644
index 000000000..59b9821c8
--- /dev/null
+++ b/cli/src/application/use-cases/plugin/plugin-target-resolution.ts
@@ -0,0 +1,42 @@
+import { McpCapability } from "../../../domain/capabilities/mcp-capability.js";
+import type { PluginsCapability } from "../../../domain/capabilities/plugins-capability.js";
+import type { Manifest } from "../../../domain/models/manifest.js";
+import type { AiToolId } from "../../../domain/models/tool-ids.js";
+import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js";
+import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js";
+
+export function resolvePluginToolIds(toolIds: AiToolId[] | "all", manifest: Manifest): AiToolId[] {
+ if (toolIds !== "all") return toolIds;
+ return AI_TOOL_IDS.filter((id) => manifest.hasTool(id)) as AiToolId[];
+}
+
+/** The base directory a plugin's files live under: `projectRoot` for project-scope
+ * plugins, the home-relative dir `PluginsCapability` resolves for user-scope ones. */
+export function resolvePluginBaseDirForCapability(
+ plugins: PluginsCapability,
+ projectRoot: string,
+ homedir: () => string
+): string {
+ return plugins.resolvePluginsBaseDir(projectRoot, homedir());
+}
+
+export function resolvePluginBaseDir(
+ toolId: AiToolId,
+ projectRoot: string,
+ homedir: () => string
+): string {
+ const toolConfig = getToolConfig(toolId);
+ if (!isAiTool(toolConfig)) return projectRoot;
+ const caps = toolConfig.capabilities as Record;
+ if (!("plugins" in caps)) return projectRoot;
+ return resolvePluginBaseDirForCapability(caps.plugins as PluginsCapability, projectRoot, homedir);
+}
+
+export function isFrameworkPrimeFlatMcp(caps: Record): boolean {
+ if (!("mcp" in caps)) return false;
+ const mcp = caps.mcp;
+ if (!(mcp instanceof McpCapability)) return false;
+ if (mcp.params.mergeStrategy !== "framework-prime") return false;
+ const plugins = caps.plugins as PluginsCapability;
+ return plugins.mode === "flat";
+}
diff --git a/cli/src/application/use-cases/plugin/plugin-update-use-case.ts b/cli/src/application/use-cases/plugin/plugin-update-use-case.ts
index e737d1501..86b935286 100644
--- a/cli/src/application/use-cases/plugin/plugin-update-use-case.ts
+++ b/cli/src/application/use-cases/plugin/plugin-update-use-case.ts
@@ -19,10 +19,9 @@ import {
deleteOldFiles,
loadPluginManifest,
materializeViaTranslator,
- resolvePluginBaseDir,
- resolvePluginToolIds,
writePluginFiles,
-} from "./plugin-helpers.js";
+} from "./plugin-file-sync.js";
+import { resolvePluginBaseDir, resolvePluginToolIds } from "./plugin-target-resolution.js";
import type { PluginTranslator } from "./translator/plugin-translator.js";
import { resolvePluginTranslator } from "./translator/resolve-plugin-translator.js";
diff --git a/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts b/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts
index 0c4fadfad..3659a55b8 100644
--- a/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts
+++ b/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts
@@ -1,4 +1,5 @@
import { join } from "node:path";
+import { flatHooksSharedDirPath } from "../../../../domain/formats/flat-paths.js";
import { InstallationFile } from "../../../../domain/models/file.js";
import type { Manifest } from "../../../../domain/models/manifest.js";
import { Plugin } from "../../../../domain/models/plugin.js";
@@ -11,9 +12,14 @@ import type { FileWriter } from "../../../../domain/ports/file-writer.js";
import type { Hasher } from "../../../../domain/ports/hasher.js";
import type { MarketplaceRegistry } from "../../../../domain/ports/marketplace-registry.js";
import type { EnsureBuiltMarketplaceUseCase } from "../../shared/ensure-built-marketplace-use-case.js";
-import { isPluginFileAtDesiredState, resolvePluginBaseDir } from "../plugin-helpers.js";
+import { isPluginFileAtDesiredState } from "../plugin-file-sync.js";
+import { resolvePluginBaseDir } from "../plugin-target-resolution.js";
import { ModeBFlatMaterializationTranslator } from "./mode-b-flat-materialization-translator.js";
import type { PluginTranslator } from "./plugin-translator.js";
+import {
+ ProjectHooksMaterializer,
+ resolvePluginsCapability,
+} from "./project-hooks-materializer.js";
/**
* Materializes plugin content by copying the per-target BUILT tree verbatim into the
@@ -26,6 +32,7 @@ import type { PluginTranslator } from "./plugin-translator.js";
*/
export class BuiltTreeMaterializationTranslator implements PluginTranslator {
readonly mode = "flat" as const;
+ private readonly projectHooks: ProjectHooksMaterializer;
constructor(
private readonly fs: FileWriter & FileReader,
@@ -33,7 +40,9 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
private readonly homedir: () => string,
private readonly ensureBuilt: EnsureBuiltMarketplaceUseCase,
private readonly marketplaceRegistry: MarketplaceRegistry
- ) {}
+ ) {
+ this.projectHooks = new ProjectHooksMaterializer(fs);
+ }
async addPlugin(
dist: PluginDistribution,
@@ -66,13 +75,25 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
target: toolId,
mode,
});
- const files =
+ const builtFiles =
mode === "flat"
- ? await this.readFlatFiles(builtDir, dist.manifest.name)
+ ? await this.readFlatFiles(builtDir, dist, toolId)
: await this.readBuiltFiles(
join(builtDir, "plugins", dist.manifest.name),
dist.manifest.name
);
+ // The built tree still carries a plugin-scoped hooks/hooks.json for a capability
+ // declaring hooksDestination "project" (the marketplace build never learned that
+ // route exists) — dropped here, and materialized through the same project-hooks
+ // side channel the local-source route uses, so both land in the one place the
+ // tool's own declaration names, not wherever this particular build happened to put it.
+ const deliversHooksToProject = resolvePluginsCapability(toolId)?.hooksDestination === "project";
+ const hooksSkips = deliversHooksToProject
+ ? await this.projectHooks.materialize(dist, toolId, projectRoot)
+ : [];
+ const files = deliversHooksToProject
+ ? withoutHooksPrefix(builtFiles, dist.manifest.name)
+ : builtFiles;
const baseDir =
mode === "flat" ? projectRoot : resolvePluginBaseDir(toolId, projectRoot, this.homedir);
const written = await this.writeChangedFiles(files, baseDir);
@@ -80,7 +101,7 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
toolId,
Plugin.fromDistribution(dist, source, files, new Map(), marketplace)
);
- return { skipped: [], written };
+ return { skipped: hooksSkips, written };
}
// Verbatim-copies the built subtree, but skips files already matching the built
@@ -115,14 +136,23 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
);
}
- // Flat build emits the whole marketplace into one workspace, namespaced by
- // .opencode//-/...; install copies only this plugin's files.
- private async readFlatFiles(builtDir: string, name: string): Promise {
+ // Flat build emits the whole marketplace into one workspace. Skills/agents are
+ // namespaced by .opencode//-/...; install copies only this
+ // plugin's files by that prefix. Hooks are not namespaced — flatHooksDir is one
+ // directory the tool's loader scans flat (see flatHooksSharedDirPath) — so this
+ // plugin's own hook filenames are matched by name instead, from its own distribution.
+ private async readFlatFiles(
+ builtDir: string,
+ dist: PluginDistribution,
+ toolId: AiToolId
+ ): Promise {
+ const name = dist.manifest.name;
+ const hookPaths = this.flatHookOutputPaths(dist, toolId);
const absPaths = await this.fs.listFilesRecursive(builtDir);
const files: InstallationFile[] = [];
for (const abs of absPaths) {
const rel = abs.slice(builtDir.length + 1);
- if (!this.belongsToPlugin(rel, name)) continue;
+ if (!this.belongsToPlugin(rel, name) && !hookPaths.has(rel)) continue;
const content = await this.fs.readFile(abs);
files.push(
new InstallationFile({ relativePath: rel, content, hash: this.hasher.hash(content) })
@@ -138,6 +168,16 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
);
}
+ private flatHookOutputPaths(dist: PluginDistribution, toolId: AiToolId): ReadonlySet {
+ const flatHooksDir = resolvePluginsCapability(toolId)?.flatHooksDir;
+ if (flatHooksDir === null || flatHooksDir === undefined) return new Set();
+ return new Set(
+ dist.components.hooks
+ .filter((f) => f.relativePath !== "hooks/hooks.json")
+ .map((f) => flatHooksSharedDirPath(flatHooksDir, f.relativePath))
+ );
+ }
+
private async findMarketplace(name: string, projectRoot: string) {
const all = await this.marketplaceRegistry.list(projectRoot);
return all.find((m) => m.name === name) ?? null;
@@ -147,3 +187,10 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator {
return new ModeBFlatMaterializationTranslator(this.fs, this.hasher, this.homedir);
}
}
+
+// readBuiltFiles prefixes every path with "/" (see its own comment above) — a
+// built-tree hooks file therefore always reads "/hooks/".
+function withoutHooksPrefix(files: InstallationFile[], pluginName: string): InstallationFile[] {
+ const hooksPrefix = `${pluginName}/hooks/`;
+ return files.filter((f) => !f.relativePath.startsWith(hooksPrefix));
+}
diff --git a/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts b/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts
index 723947015..c3a9ca991 100644
--- a/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts
+++ b/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts
@@ -18,12 +18,13 @@ import type { FileReader } from "../../../../domain/ports/file-reader.js";
import type { FileWriter } from "../../../../domain/ports/file-writer.js";
import type { Hasher } from "../../../../domain/ports/hasher.js";
import { getToolConfig, isAiTool } from "../../../../domain/tools/registry.js";
+import { writePluginFiles } from "../plugin-file-sync.js";
import {
- qualifiesForOpencodeMcpMerge,
+ isFrameworkPrimeFlatMcp,
resolvePluginBaseDirForCapability,
- writePluginFiles,
-} from "../plugin-helpers.js";
+} from "../plugin-target-resolution.js";
import type { PluginTranslator } from "./plugin-translator.js";
+import { ProjectHooksMaterializer, withoutHooks } from "./project-hooks-materializer.js";
/**
* Mode B — Flat materialization.
@@ -34,12 +35,15 @@ import type { PluginTranslator } from "./plugin-translator.js";
*/
export class ModeBFlatMaterializationTranslator implements PluginTranslator {
readonly mode = "flat" as const;
+ private readonly projectHooks: ProjectHooksMaterializer;
constructor(
private readonly fs: FileWriter & FileReader,
private readonly hasher: Hasher,
private readonly homedir: () => string
- ) {}
+ ) {
+ this.projectHooks = new ProjectHooksMaterializer(fs);
+ }
async addPlugin(
dist: PluginDistribution,
@@ -54,7 +58,8 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator {
const ctx = this.resolveFlatToolContext(toolId, dist, docsDir, projectRoot);
if (ctx === null) return { skipped: [] };
const mcp = await this.resolveMcp(dist, toolId, projectRoot, previousMcpEntries);
- const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips];
+ const hooksSkips = await this.projectHooks.materialize(dist, toolId, projectRoot);
+ const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooksSkips];
if (ctx.files.length === 0 && mcp.mcpEntries.size === 0) return { skipped: allSkipped };
await this.writeAndRegisterPlugin(
dist,
@@ -89,9 +94,10 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator {
if (pluginsCap.mode === "native" && pluginsCap.installScope !== "user") {
throw new CursorProjectScopeUnsupportedError();
}
+ const distForNative = pluginsCap.hooksDestination === "project" ? withoutHooks(dist) : dist;
const { files, componentPaths, skipped } = new PluginContentTranslator(
this.hasher
- ).translateWithComponentPaths(dist, toolConfig, docsDir);
+ ).translateWithComponentPaths(distForNative, toolConfig, docsDir);
const baseDir = resolvePluginBaseDirForCapability(pluginsCap, projectRoot, this.homedir);
return { caps, files, componentPaths, skipped, baseDir };
}
@@ -105,7 +111,7 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator {
const toolConfig = getToolConfig(toolId);
if (!isAiTool(toolConfig)) return { mcpEntries: new Map(), mcpSkips: [] };
const caps = toolConfig.capabilities as Record;
- if (!qualifiesForOpencodeMcpMerge(caps) || dist.components.mcp.length === 0) {
+ if (!isFrameworkPrimeFlatMcp(caps) || dist.components.mcp.length === 0) {
return { mcpEntries: new Map(), mcpSkips: [] };
}
return this.mergeOpencodeMcpEntries(dist, caps, projectRoot, previousMcpEntries, toolId);
diff --git a/cli/src/application/use-cases/plugin/translator/project-hooks-materializer.ts b/cli/src/application/use-cases/plugin/translator/project-hooks-materializer.ts
new file mode 100644
index 000000000..85b7432ee
--- /dev/null
+++ b/cli/src/application/use-cases/plugin/translator/project-hooks-materializer.ts
@@ -0,0 +1,110 @@
+import { join } from "node:path";
+import type { PluginsCapability } from "../../../../domain/capabilities/plugins-capability.js";
+import {
+ cursorProjectHooksScriptPath,
+ mergeCursorProjectHooksJson,
+} from "../../../../domain/formats/cursor-hooks-project-merge.js";
+import {
+ type PluginComponentFile,
+ PluginDistribution,
+} from "../../../../domain/models/plugin-distribution.js";
+import type {
+ PluginTranslationSkip,
+ ReadonlySkipList,
+} from "../../../../domain/models/plugin-translation-skip.js";
+import type { AiToolId } from "../../../../domain/models/tool-ids.js";
+import type { FileReader } from "../../../../domain/ports/file-reader.js";
+import type { FileWriter } from "../../../../domain/ports/file-writer.js";
+import { getToolConfig, isAiTool } from "../../../../domain/tools/registry.js";
+
+const HOOKS_MANIFEST_PATH = "hooks/hooks.json";
+
+/**
+ * Delivers a plugin's hooks to the destination a `hooksDestination: "project"`
+ * capability names — merged into the project's own hooks file, scripts copied
+ * beside it — rather than into the plugin's own directory. The single place both
+ * materialization routes (Mode B flat, and the marketplace-sourced built-tree copy)
+ * call, so where a tool's hooks land is decided by its own declaration, never by
+ * which translator happened to run — see measurements.md, Phase 7, Task 2.
+ */
+export class ProjectHooksMaterializer {
+ constructor(private readonly fs: FileWriter & FileReader) {}
+
+ async materialize(
+ dist: PluginDistribution,
+ toolId: AiToolId,
+ projectRoot: string
+ ): Promise {
+ const pluginsCap = resolvePluginsCapability(toolId);
+ if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return [];
+ const manifestFile = dist.components.hooks.find((f) => f.relativePath === HOOKS_MANIFEST_PATH);
+ if (manifestFile === undefined) return [];
+ const warnings = await this.mergeProjectHooksJson(dist, manifestFile, projectRoot);
+ await this.writeProjectHooksScripts(dist, projectRoot);
+ return warnings.map(
+ (reason): PluginTranslationSkip => ({
+ pluginName: dist.manifest.name,
+ component: "hooks",
+ toolId,
+ reason,
+ })
+ );
+ }
+
+ private async mergeProjectHooksJson(
+ dist: PluginDistribution,
+ manifestFile: PluginComponentFile,
+ projectRoot: string
+ ): Promise {
+ const destPath = join(projectRoot, ".cursor", "hooks.json");
+ const existing = await this.readExistingJson(destPath);
+ const { content, warnings } = mergeCursorProjectHooksJson(
+ existing,
+ manifestFile.content,
+ dist.manifest.name
+ );
+ await this.fs.writeFile(destPath, content);
+ return warnings;
+ }
+
+ private async writeProjectHooksScripts(
+ dist: PluginDistribution,
+ projectRoot: string
+ ): Promise {
+ for (const file of dist.components.hooks) {
+ if (file.relativePath === HOOKS_MANIFEST_PATH) continue;
+ const dest = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath);
+ await this.fs.writeFile(join(projectRoot, dest), file.content);
+ }
+ }
+
+ private async readExistingJson(path: string): Promise {
+ try {
+ return await this.fs.readFile(path);
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
+ throw err;
+ }
+ }
+}
+
+/** A copy of `dist` with every `hooks/` file dropped, both from `files` (what the
+ * generic native translator walks) and from `components.hooks` (what a hooks-trust
+ * notice reads) — for a capability declaring `hooksDestination: "project"`, so none
+ * of its hooks are written under the plugin's own directory, only via `materialize`. */
+export function withoutHooks(dist: PluginDistribution): PluginDistribution {
+ return new PluginDistribution({
+ manifest: dist.manifest,
+ format: dist.format,
+ files: dist.files.filter((f) => f.relativePath.split("/")[0] !== "hooks"),
+ components: { ...dist.components, hooks: [] },
+ });
+}
+
+export function resolvePluginsCapability(toolId: AiToolId): PluginsCapability | null {
+ const toolConfig = getToolConfig(toolId);
+ if (!isAiTool(toolConfig)) return null;
+ const caps = toolConfig.capabilities as Record;
+ if (!("plugins" in caps)) return null;
+ return caps.plugins as PluginsCapability;
+}
diff --git a/cli/src/application/use-cases/shared/apply-plugin-files-use-case.ts b/cli/src/application/use-cases/shared/apply-plugin-files-use-case.ts
index f2ca30ee2..e1499f8c3 100644
--- a/cli/src/application/use-cases/shared/apply-plugin-files-use-case.ts
+++ b/cli/src/application/use-cases/shared/apply-plugin-files-use-case.ts
@@ -15,8 +15,8 @@ import {
deleteOldFiles,
isPluginFileAtDesiredState,
materializeViaTranslator,
- resolvePluginBaseDir,
-} from "../plugin/plugin-helpers.js";
+} from "../plugin/plugin-file-sync.js";
+import { resolvePluginBaseDir } from "../plugin/plugin-target-resolution.js";
import type { PluginTranslator } from "../plugin/translator/plugin-translator.js";
import { resolvePluginTranslator } from "../plugin/translator/resolve-plugin-translator.js";
import type { EnsureBuiltMarketplaceUseCase } from "./ensure-built-marketplace-use-case.js";
diff --git a/cli/src/application/use-cases/shared/detect-plugin-drift-use-case.ts b/cli/src/application/use-cases/shared/detect-plugin-drift-use-case.ts
index a07537688..5693131c4 100644
--- a/cli/src/application/use-cases/shared/detect-plugin-drift-use-case.ts
+++ b/cli/src/application/use-cases/shared/detect-plugin-drift-use-case.ts
@@ -4,7 +4,7 @@ import type { Manifest } from "../../../domain/models/manifest.js";
import type { AiToolId } from "../../../domain/models/tool-ids.js";
import type { FileReader } from "../../../domain/ports/file-reader.js";
import type { ToolId } from "../../../domain/tools/registry.js";
-import { resolvePluginBaseDir } from "../plugin/plugin-helpers.js";
+import { resolvePluginBaseDir } from "../plugin/plugin-target-resolution.js";
export type PluginFileDriftKind = "missing" | "hash-mismatch";
diff --git a/cli/src/application/use-cases/shared/post-install-pipeline-use-case.ts b/cli/src/application/use-cases/shared/post-install-pipeline-use-case.ts
index c98468c0b..c954e15fd 100644
--- a/cli/src/application/use-cases/shared/post-install-pipeline-use-case.ts
+++ b/cli/src/application/use-cases/shared/post-install-pipeline-use-case.ts
@@ -1,5 +1,5 @@
import type { Manifest } from "../../../domain/models/manifest.js";
-import { AIDD_DIR } from "../../../domain/models/paths.js";
+import { AIDD_DIR, DOCS_DIR } from "../../../domain/models/paths.js";
import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
import type { GitignoreUseCase } from "./gitignore-use-case.js";
@@ -18,6 +18,8 @@ export class PostInstallPipelineUseCase {
const { projectRoot, manifest } = options;
await this.manifestRepo.save(manifest);
- await this.gitignoreUseCase.execute(projectRoot, [`${AIDD_DIR}/cache/`]);
+ // The run journal: who worked on what, for how long, and every file a session wrote.
+ // It belongs to the repository it describes, so it must never be offered to a commit.
+ await this.gitignoreUseCase.execute(projectRoot, [`${AIDD_DIR}/cache/`, `${DOCS_DIR}/runs/`]);
}
}
diff --git a/cli/src/application/use-cases/telemetry/enable-tool-telemetry-use-case.ts b/cli/src/application/use-cases/telemetry/enable-tool-telemetry-use-case.ts
new file mode 100644
index 000000000..b51c9cc14
--- /dev/null
+++ b/cli/src/application/use-cases/telemetry/enable-tool-telemetry-use-case.ts
@@ -0,0 +1,78 @@
+import { relative } from "node:path";
+import type {
+ TelemetryScope,
+ TelemetrySettingsFileActivation,
+} from "../../../domain/capabilities/telemetry-capability.js";
+import type { FileHash } from "../../../domain/models/file.js";
+import type { Manifest } from "../../../domain/models/manifest.js";
+import { hashJsonEntries, type MergeFileEntry } from "../../../domain/models/merge.js";
+import type { AiToolId } from "../../../domain/models/tool-ids.js";
+import type { FileMerger } from "../../../domain/ports/file-merger.js";
+import type { Hasher } from "../../../domain/ports/hasher.js";
+import type { Logger } from "../../../domain/ports/logger.js";
+import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
+import { NoManifestError } from "../../errors.js";
+
+export interface EnableToolTelemetryOptions {
+ readonly toolId: AiToolId;
+ readonly activation: TelemetrySettingsFileActivation;
+ readonly projectRoot: string;
+ readonly homeDir: string;
+ readonly endpoint: string | undefined;
+ readonly projectId: string;
+ readonly scope: TelemetryScope;
+}
+
+export interface EnableToolTelemetryResult {
+ readonly settingsPath: string;
+ readonly env: Readonly>;
+}
+
+/** Enables a tool's OTLP export by upserting the key set its `settings-file` activation
+ * builds into the scope-resolved settings file, through the same merge-tracking machinery
+ * `aidd clean` already knows how to undo. Never touches a key it did not add. Everything
+ * about the tool's on-disk shape — where the file lives, what section holds the keys, what
+ * the keys are — comes from `options.activation`; this class knows none of it. */
+export class EnableToolTelemetryUseCase {
+ constructor(
+ private readonly fs: FileMerger,
+ private readonly hasher: Hasher,
+ private readonly manifestRepo: ManifestRepository,
+ private readonly logger: Logger
+ ) {}
+
+ async execute(options: EnableToolTelemetryOptions): Promise {
+ const manifest = await this.manifestRepo.load();
+ if (manifest === null) throw new NoManifestError();
+ const { activation, toolId } = options;
+ const env = activation.buildEnv(options.endpoint, options.projectId);
+ const settingsPath = activation.resolveSettingsPath(
+ options.scope,
+ options.projectRoot,
+ options.homeDir
+ );
+ this.logger.info(`${toolId} telemetry -> ${settingsPath}`);
+ const payload = JSON.stringify({ [activation.sectionKey]: env });
+ await this.fs.mergeJsonFile(settingsPath, payload, activation.mergeStrategy);
+ this.trackMergeFile(manifest, options, env, settingsPath);
+ await this.manifestRepo.save(manifest);
+ if (activation.postEnableNotice) this.logger.info(activation.postEnableNotice);
+ return { settingsPath, env };
+ }
+
+ private trackMergeFile(
+ manifest: Manifest,
+ options: EnableToolTelemetryOptions,
+ env: Readonly>,
+ settingsPath: string
+ ): void {
+ const { toolId, activation, projectRoot } = options;
+ const relativePath = relative(projectRoot, settingsPath).replace(/\\/g, "/");
+ const entries: Record = hashJsonEntries(env, this.hasher);
+ const newEntry: MergeFileEntry = { relativePath, sectionKey: activation.sectionKey, entries };
+ const otherEntries = manifest
+ .getMergeFiles(toolId)
+ .filter((m) => !(m.relativePath === relativePath && m.sectionKey === activation.sectionKey));
+ manifest.updateToolMergeFiles(toolId, [...otherEntries, newEntry]);
+ }
+}
diff --git a/cli/src/application/use-cases/telemetry/read-local-cost-use-case.ts b/cli/src/application/use-cases/telemetry/read-local-cost-use-case.ts
new file mode 100644
index 000000000..ebd59ccca
--- /dev/null
+++ b/cli/src/application/use-cases/telemetry/read-local-cost-use-case.ts
@@ -0,0 +1,334 @@
+import type { TelemetryLocalRead } from "../../../domain/capabilities/telemetry-capability.js";
+import {
+ resolveSessionProject,
+ type SessionProject,
+} from "../../../domain/models/session-project.js";
+import {
+ attributeMoment,
+ buildStepIntervals,
+ type StepInterval,
+} from "../../../domain/models/step-attribution.js";
+import {
+ SINK_SCHEMA_VERSION,
+ type TelemetrySinkRecord,
+} from "../../../domain/models/telemetry-sink-record.js";
+import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js";
+import type { RunJournalReader } from "../../../domain/ports/run-journal-reader.js";
+import type {
+ LocalCostCandidateRecord,
+ LocalCostReadResult,
+ SessionCostReader,
+} from "../../../domain/ports/session-cost-reader.js";
+import type { TelemetrySink } from "../../../domain/ports/telemetry-sink.js";
+import { getAiToolConfig } from "../../../domain/tools/registry.js";
+
+/** Five answers, and only one of them may ever be printed as a zero.
+ *
+ * - `found` — this tool held the session and billed for it.
+ * - `empty` — it held the session and billed nothing. The zero is the measurement.
+ * - `not-found` — it has no trace of the session at all. Nothing is known about it.
+ * - `unreadable` — its reader failed. Nothing is known about it, and something is wrong.
+ * - `not-covered` — nothing here can read this tool, and its declaration says why.
+ *
+ * The last four look alike in a total and mean four different things. Collapsing any of
+ * them into `empty` is exactly how a session that was never measured reads as free. */
+export type LocalCostToolStatus = "found" | "empty" | "not-found" | "unreadable" | "not-covered";
+
+export interface LocalCostToolReport {
+ readonly tool: AiToolId;
+ readonly status: LocalCostToolStatus;
+ /** Records the reader returned, before dedup — this is what makes "found" and "empty"
+ * distinguishable from each other, independent of how many were new. */
+ readonly recordsFound: number;
+ /** Records newly appended to the sink; a re-read of an already-stored session can be
+ * `status: "found"` with `recordsStored: 0`. */
+ readonly recordsStored: number;
+ /** Why this tool is not covered, or — for a covered one — what its figures cannot yet be
+ * used for; both come from the declaration. On `unreadable` it is what the reader itself
+ * said, since only the reader knows why it could not answer. */
+ readonly reason?: string;
+ /** Sessions this tool's reader threw on. Carried separately from `status` because a
+ * sweep can read nineteen sessions and fail the twentieth: the figures are real, so the
+ * status is `found`, and a failure that only showed up in the status would vanish
+ * exactly when there is most to lose. Zero on a single-session read that succeeded. */
+ readonly sessionsFailed: number;
+ /** What the last failed session's reader said, when any failed. */
+ readonly failureReason?: string;
+}
+
+export interface ReadLocalCostOptions {
+ /** One session by name. Absent reads every session the run journal knows about — the
+ * only route a person has, since nothing tells them a session identifier. */
+ readonly sessionId?: string;
+ readonly at?: Date;
+}
+
+/** What one session's read produced. `sessionId` is on the report because a sweep answers
+ * about several and a caller has to be able to tell them apart. */
+export interface LocalCostSessionReport {
+ readonly sessionId: string;
+ readonly toolReports: readonly LocalCostToolReport[];
+}
+
+export interface ReadLocalCostResult {
+ readonly sessions: readonly LocalCostSessionReport[];
+ /** Every tool's answer across every session read, so a caller sees one line per tool
+ * rather than one per tool per session. */
+ readonly toolReports: readonly LocalCostToolReport[];
+}
+
+/** Reads what every locally-readable tool's own files hold for one session, normalises it
+ * into the stored shape, and appends what is not already there. Which tools are readable
+ * is a declaration in `domain/tools/ai/*.ts`, read through the registry — this class names
+ * no tool. Which adapter serves a declared tool is decided once, at the composition root,
+ * and handed in as `readers`. */
+function isPresent(value: string | undefined): value is string {
+ return value !== undefined;
+}
+
+/** The strongest answer a tool gave anywhere in the sweep.
+ *
+ * A tool that read one session and could not read another reports as `found`: the figures
+ * it produced are real, and calling the whole tool broken would discard them. The failure
+ * does not disappear with the status — `sessionsFailed` counts it separately, precisely so
+ * that a status which is honest about the figures cannot also be a silence about the
+ * failures. `unreadable` outranks the two silences for the mirror reason. */
+const STATUS_RANK: readonly LocalCostToolStatus[] = [
+ "found",
+ "unreadable",
+ "empty",
+ "not-found",
+ "not-covered",
+];
+
+function strongestOf(tool: AiToolId, reports: readonly LocalCostToolReport[]): LocalCostToolReport {
+ const nothingKnown: LocalCostToolReport = {
+ tool,
+ status: "not-found",
+ recordsFound: 0,
+ recordsStored: 0,
+ sessionsFailed: 0,
+ };
+ return reports.reduce(
+ (strongest, report) =>
+ STATUS_RANK.indexOf(report.status) < STATUS_RANK.indexOf(strongest.status)
+ ? report
+ : strongest,
+ reports[0] ?? nothingKnown
+ );
+}
+
+function mergeOneTool(
+ tool: AiToolId,
+ sessions: readonly LocalCostSessionReport[]
+): LocalCostToolReport {
+ const reports = sessions.flatMap((session) =>
+ session.toolReports.filter((report) => report.tool === tool)
+ );
+ const failures = reports
+ .map((report) => report.failureReason)
+ .filter((reason): reason is string => reason !== undefined);
+ return {
+ ...strongestOf(tool, reports),
+ recordsFound: reports.reduce((sum, report) => sum + report.recordsFound, 0),
+ recordsStored: reports.reduce((sum, report) => sum + report.recordsStored, 0),
+ sessionsFailed: failures.length,
+ ...(failures.length === 0 ? {} : { failureReason: failures[failures.length - 1] }),
+ };
+}
+
+/** Nothing here can read this tool at all, with the reason its declaration gives. */
+function notCovered(tool: AiToolId, localRead: TelemetryLocalRead): LocalCostToolReport {
+ return {
+ tool,
+ status: "not-covered",
+ recordsFound: 0,
+ recordsStored: 0,
+ sessionsFailed: 0,
+ ...(localRead.kind === "unsupported" ? { reason: localRead.reason } : {}),
+ };
+}
+
+/** Its reader failed, so nothing is known about it and something is wrong — distinct from
+ * `not-found`, where nothing is known and nothing is wrong. */
+function unreadable(tool: AiToolId, failure: string): LocalCostToolReport {
+ return {
+ tool,
+ status: "unreadable",
+ recordsFound: 0,
+ recordsStored: 0,
+ sessionsFailed: 1,
+ reason: failure,
+ failureReason: failure,
+ };
+}
+
+function mergeToolReports(
+ sessions: readonly LocalCostSessionReport[]
+): readonly LocalCostToolReport[] {
+ return AI_TOOL_IDS.map((tool) => mergeOneTool(tool, sessions));
+}
+
+export class ReadLocalCostUseCase {
+ constructor(
+ private readonly sink: TelemetrySink,
+ private readonly readers: ReadonlyMap,
+ private readonly runJournalReader: RunJournalReader
+ ) {}
+
+ async execute(options: ReadLocalCostOptions): Promise {
+ const at = options.at ?? new Date();
+ const sessionIds =
+ options.sessionId === undefined ? await this.journalledSessionIds() : [options.sessionId];
+ const sessions: LocalCostSessionReport[] = [];
+ for (const sessionId of sessionIds) {
+ sessions.push({ sessionId, toolReports: await this.readOneSession(sessionId, at) });
+ }
+ return { sessions, toolReports: mergeToolReports(sessions) };
+ }
+
+ /** Every session the journal names, oldest file first. A person has no other way to
+ * learn a session identifier, and the journal has recorded every one of them since #663. */
+ private async journalledSessionIds(): Promise {
+ const journals = await this.runJournalReader.list();
+ const ids = journals.map((journal) => journal.session?.vendor_id).filter(isPresent);
+ return [...new Set(ids)];
+ }
+
+ private async readOneSession(
+ sessionId: string,
+ at: Date
+ ): Promise {
+ // Read once per session, never per tool: every reader's candidates for one session are
+ // joined against the same journal. A session with no journal at all — the reader's
+ // contract promises never to throw for that — yields an empty interval list, so every
+ // candidate falls through to unattributed rather than the read failing; the project is
+ // `null` for the same reason, never re-derived from wherever this process runs.
+ const journal = await this.runJournalReader.read(sessionId);
+ const intervals = journal ? buildStepIntervals(journal) : [];
+ const project = resolveSessionProject(journal);
+ const toolReports: LocalCostToolReport[] = [];
+ for (const tool of AI_TOOL_IDS) {
+ toolReports.push(await this.readOneTool(tool, sessionId, at, intervals, project));
+ }
+ return toolReports;
+ }
+
+ private async readOneTool(
+ tool: AiToolId,
+ sessionId: string,
+ at: Date,
+ intervals: readonly StepInterval[],
+ project: SessionProject | null
+ ): Promise {
+ const localRead = getAiToolConfig(tool).telemetryLocalRead;
+ if (localRead.kind !== "declared") return notCovered(tool, localRead);
+ const attempt = await this.attemptRead(tool, sessionId);
+ if ("failure" in attempt) return unreadable(tool, attempt.failure);
+ const candidates = attempt.records;
+ const recordsStored = await this.storeNewCandidates(
+ tool,
+ sessionId,
+ candidates,
+ at,
+ intervals,
+ project
+ );
+ return {
+ tool,
+ status: candidates.length > 0 ? "found" : attempt.sessionFound ? "empty" : "not-found",
+ recordsFound: candidates.length,
+ recordsStored,
+ sessionsFailed: 0,
+ ...(localRead.limitation !== undefined ? { reason: localRead.limitation } : {}),
+ };
+ }
+
+ /** The one place this use case catches, and it catches for a reason the architecture's
+ * "use-cases throw, never catch" rule does not cover: this is a fan-out over independent
+ * sources, so a reader failing is not one operation that failed but one of several. A
+ * throw here would cost every other tool's figures for a session none of them had any
+ * trouble with — and, once a sweep reads every journalled session, every other session's
+ * too. See https://github.com/ai-driven-dev/framework/issues/689. */
+ private async attemptRead(
+ tool: AiToolId,
+ sessionId: string
+ ): Promise {
+ const reader = this.readers.get(tool);
+ if (!reader) return { records: [], sessionFound: false };
+ try {
+ return await reader.read(sessionId);
+ } catch (error) {
+ return { failure: error instanceof Error ? error.message : String(error) };
+ }
+ }
+
+ /** Matches each candidate against what the sink already holds for this session, on
+ * `turn_id` alone — never a hash of the line, since the tool's own file keeps growing
+ * as the same record is read again. A candidate with no `turn_id` cannot be matched and
+ * is always appended: the reader's contract forbids inventing a key for it. */
+ private async storeNewCandidates(
+ tool: AiToolId,
+ sessionId: string,
+ candidates: readonly LocalCostCandidateRecord[],
+ at: Date,
+ intervals: readonly StepInterval[],
+ project: SessionProject | null
+ ): Promise {
+ if (candidates.length === 0) return 0;
+ const existing = await this.sink.readRecordsForVendor(sessionId);
+ const storedTurnIds = new Set(
+ existing.map((record) => record.turn_id).filter((id): id is string => id !== undefined)
+ );
+ let stored = 0;
+ for (const candidate of candidates) {
+ if (candidate.turn_id !== undefined && storedTurnIds.has(candidate.turn_id)) continue;
+ await this.sink.appendRecord(
+ this.stampProvenanceAndTool(tool, candidate, intervals, project),
+ at
+ );
+ stored++;
+ }
+ return stored;
+ }
+
+ // The caller asked this tool's reader by name — that is the fact this stamps, never
+ // inferred from the candidate itself, which the reader's contract forbids it naming.
+ private stampProvenanceAndTool(
+ tool: AiToolId,
+ candidate: LocalCostCandidateRecord,
+ intervals: readonly StepInterval[],
+ project: SessionProject | null
+ ): TelemetrySinkRecord {
+ return {
+ ...candidate,
+ sink_schema_version: SINK_SCHEMA_VERSION,
+ provenance: "local-read",
+ tool,
+ ...this.resolveStepAttribution(candidate, intervals),
+ ...(project === null
+ ? {}
+ : { project_id: project.projectId, project_field: project.projectField }),
+ };
+ }
+
+ // Where the candidate itself carries `step`, the tool stated it directly (see
+ // claude-code-transcript.ts) — exact, and never second-guessed by an interval, which is
+ // only ever an inference. Everything else falls back to the journal, joined on the
+ // candidate's own moment; a candidate with no moment, or one earlier than every
+ // interval, comes back unattributed rather than folded into the nearest step.
+ private resolveStepAttribution(
+ candidate: LocalCostCandidateRecord,
+ intervals: readonly StepInterval[]
+ ): Pick {
+ if (candidate.step !== undefined) {
+ return {
+ step_attribution: "tool-stated",
+ step: candidate.step,
+ step_plugin: candidate.step_plugin,
+ };
+ }
+ const attribution = attributeMoment(intervals, candidate.event_timestamp);
+ return { step_attribution: attribution.source, step: attribution.step, step_plugin: undefined };
+ }
+}
diff --git a/cli/src/application/use-cases/telemetry/receive-telemetry-use-case.ts b/cli/src/application/use-cases/telemetry/receive-telemetry-use-case.ts
new file mode 100644
index 000000000..5b7f8d72a
--- /dev/null
+++ b/cli/src/application/use-cases/telemetry/receive-telemetry-use-case.ts
@@ -0,0 +1,114 @@
+import type { TelemetryExportDeclared } from "../../../domain/capabilities/telemetry-capability.js";
+import {
+ mapOtlpLogsToSinkRecords,
+ mapOtlpMetricsToSinkRecords,
+ type TelemetrySessionMeasure,
+ type TelemetryVendorIdentity,
+} from "../../../domain/models/telemetry-sink-record.js";
+import {
+ DEFAULT_TELEMETRY_SINK_RETENTION_DAYS,
+ decideTelemetrySinkRetention,
+} from "../../../domain/models/telemetry-sink-retention.js";
+import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js";
+import type { Logger } from "../../../domain/ports/logger.js";
+import type { TelemetrySink } from "../../../domain/ports/telemetry-sink.js";
+import { getAiToolConfig } from "../../../domain/tools/registry.js";
+
+export type TelemetryOtlpPath = "/v1/logs" | "/v1/metrics" | "/v1/traces";
+
+export interface TelemetryReceiveStartResult {
+ readonly rootDir: string;
+}
+
+/** A declared export shape, paired with the tool that declared it — the pairing this
+ * file's only job is to carry forward, never to branch on. */
+interface DeclaredExport {
+ readonly toolId: AiToolId;
+ readonly shape: TelemetryExportDeclared;
+}
+
+function declaredExports(): readonly DeclaredExport[] {
+ const declared: DeclaredExport[] = [];
+ for (const toolId of AI_TOOL_IDS) {
+ const shape = getAiToolConfig(toolId).telemetryExport;
+ if (shape.kind === "declared") declared.push({ toolId, shape });
+ }
+ return declared;
+}
+
+function declaredVendorIdentities(): readonly TelemetryVendorIdentity[] {
+ return declaredExports().map(({ toolId, shape }) => ({
+ tool: toolId,
+ identityAttribute: shape.identityAttribute,
+ turnAttribute: shape.turnAttribute,
+ }));
+}
+
+function declaredSessionMeasures(): readonly TelemetrySessionMeasure[] {
+ return declaredExports().flatMap(({ shape }) => shape.sessionMeasures ?? []);
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+export class ReceiveTelemetryUseCase {
+ constructor(
+ private readonly sink: TelemetrySink,
+ private readonly logger: Logger,
+ private readonly retentionDays: number = DEFAULT_TELEMETRY_SINK_RETENTION_DAYS
+ ) {}
+
+ /** Throws if the sink directory cannot be created or written to; the caller must not
+ * start listening on that error. */
+ async start(): Promise {
+ await this.sink.ensureWritable();
+ return { rootDir: this.sink.rootDir };
+ }
+
+ /** `payload` is already-parsed JSON. `/v1/traces` is accepted and dropped: no tool
+ * measured so far puts a billed request on a span this layer reads. */
+ async receive(
+ path: TelemetryOtlpPath,
+ payload: unknown,
+ receivedAt: Date = new Date()
+ ): Promise {
+ if (path === "/v1/traces") return;
+
+ const vendors = declaredVendorIdentities();
+ const records =
+ path === "/v1/logs"
+ ? mapOtlpLogsToSinkRecords(payload, vendors)
+ : mapOtlpMetricsToSinkRecords(payload, vendors, declaredSessionMeasures());
+
+ for (const record of records) {
+ const { dayFileIsNew } = await this.sink.appendRecord(record, receivedAt);
+ if (dayFileIsNew) await this.pruneOldDayFiles();
+ }
+ }
+
+ // Catches under the long-lived-process carve-out: the payload that triggered this is
+ // already stored, and a housekeeping failure must not cost it.
+ private async pruneOldDayFiles(): Promise {
+ let prune: readonly string[];
+ try {
+ prune = decideTelemetrySinkRetention(
+ await this.sink.listDayFiles(),
+ this.retentionDays
+ ).prune;
+ } catch (error) {
+ this.logger.warn(`telemetry receive: retention prune failed — ${errorMessage(error)}`);
+ return;
+ }
+ // Per file, so one that cannot be deleted does not spare every older one behind it.
+ for (const fileName of prune) {
+ try {
+ await this.sink.deleteDayFile(fileName);
+ } catch (error) {
+ this.logger.warn(
+ `telemetry receive: could not delete ${fileName} — ${errorMessage(error)}`
+ );
+ }
+ }
+ }
+}
diff --git a/cli/src/application/use-cases/telemetry/report-cost-use-case.ts b/cli/src/application/use-cases/telemetry/report-cost-use-case.ts
new file mode 100644
index 000000000..1e6bef2ca
--- /dev/null
+++ b/cli/src/application/use-cases/telemetry/report-cost-use-case.ts
@@ -0,0 +1,111 @@
+import {
+ buildCostReport,
+ type CostReport,
+ type CostReportFilters,
+ type CostReportSessionJournal,
+ type CostReportToolCapability,
+ type CostReportToolDeclaration,
+} from "../../../domain/models/cost-report.js";
+import type { ResolvedReportPeriod } from "../../../domain/models/report-period.js";
+import { buildTaskIntervals } from "../../../domain/models/task-attribution.js";
+import type { TaskIdentity } from "../../../domain/models/task-identity.js";
+import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js";
+import type { RunJournal, RunJournalReader } from "../../../domain/ports/run-journal-reader.js";
+import type { TelemetrySink } from "../../../domain/ports/telemetry-sink.js";
+import { getAiToolConfig } from "../../../domain/tools/registry.js";
+
+export interface ReportCostOptions {
+ /** Already two absolute days. Resolving what a caller asked for is
+ * `domain/models/report-period.ts`'s job and happens once, at the edge — so nothing from
+ * here down reads a clock, and the same options answer the same twice. */
+ readonly period: ResolvedReportPeriod;
+ /** Restrict to the sessions that wrote into this task. Absent reports the whole period. */
+ readonly task?: TaskIdentity;
+ /** Any of `project`, `step`, `model` and `tool` - each optional, composing with `task`
+ * and each other by `and`. */
+ readonly filters?: CostReportFilters;
+}
+
+/** What each tool declares about being read at all, as data the pure report consumes. A
+ * tool whose own files cannot be read is `not-covered` with the reason its declaration
+ * gives, so a report prints why rather than a zero; a readable tool carries its
+ * `limitation` forward for the same reason, since a caveat that stays in a source comment
+ * reaches nobody downstream. */
+function declaredTools(): readonly CostReportToolDeclaration[] {
+ return AI_TOOL_IDS.map((tool) => {
+ const config = getAiToolConfig(tool);
+ const localRead = config.telemetryLocalRead;
+ const capability: CostReportToolCapability = {
+ localRead: localRead.kind === "declared" ? localRead.supplies : null,
+ export: config.telemetryExport.kind === "declared" ? config.telemetryExport.supplies : null,
+ journalAttributable: config.telemetryJournalHost !== undefined,
+ taskAttributable: config.telemetryTaskAttributable,
+ };
+ if (localRead.kind === "declared") {
+ return {
+ tool,
+ coverage: "covered" as const,
+ ...(localRead.limitation === undefined ? {} : { reason: localRead.limitation }),
+ capability,
+ };
+ }
+ return {
+ tool,
+ coverage: "not-covered" as const,
+ ...(localRead.kind === "unsupported" ? { reason: localRead.reason } : {}),
+ capability,
+ };
+ });
+}
+
+function toSessionJournal(journal: RunJournal): CostReportSessionJournal | null {
+ if (!journal.session) return null;
+ return {
+ vendorId: journal.session.vendor_id,
+ tool: journal.session.tool,
+ ...(journal.session.project_id === undefined ? {} : { projectId: journal.session.project_id }),
+ writtenPaths: journal.filesWritten.map((written) => written.path),
+ taskIntervals: buildTaskIntervals(journal),
+ };
+}
+
+/**
+ * Answers what a period, or one task inside it, cost.
+ *
+ * Orchestration only: the two reads belong to their ports, the rules belong to
+ * `domain/models/cost-report.ts`, and what is left is asking for one period's records and
+ * one period's journals and handing both over. It names no tool and computes no figure -
+ * in particular no amount, since the rates live outside this repository and an amount is
+ * only ever reported where a tool's own files already carried one.
+ */
+export class ReportCostUseCase {
+ constructor(
+ private readonly sink: TelemetrySink,
+ private readonly runJournalReader: RunJournalReader
+ ) {}
+
+ async execute(options: ReportCostOptions): Promise {
+ const { fromDay, toDay } = options.period;
+ const read = await this.sink.readRecordsInPeriod(
+ new Date(`${fromDay}T00:00:00Z`),
+ new Date(`${toDay}T00:00:00Z`)
+ );
+ // Every journal, not only the period's: a journal carries no date in its file name, and
+ // the records it is joined to were already selected by their own moments. Filtering the
+ // journals as well would only be a second, weaker selection over the same thing.
+ const journals = await this.runJournalReader.list();
+
+ return buildCostReport({
+ fromDay,
+ toDay,
+ records: read.records,
+ journals: journals.map(toSessionJournal).filter((journal) => journal !== null),
+ declaredTools: declaredTools(),
+ undatedRecords: read.undated.length,
+ unreadableLines: read.skippedLines,
+ ...(options.task === undefined ? {} : { task: options.task }),
+ ...(options.filters === undefined ? {} : { filters: options.filters }),
+ knownValues: read.knownValues,
+ });
+ }
+}
diff --git a/cli/src/application/use-cases/telemetry/telemetry-off-use-case.ts b/cli/src/application/use-cases/telemetry/telemetry-off-use-case.ts
new file mode 100644
index 000000000..55773dc32
--- /dev/null
+++ b/cli/src/application/use-cases/telemetry/telemetry-off-use-case.ts
@@ -0,0 +1,161 @@
+import { dirname, join } from "node:path";
+import type { Manifest } from "../../../domain/models/manifest.js";
+import {
+ isMergeContentEmpty,
+ type MergeFileEntry,
+ removeEntriesFromJson,
+} from "../../../domain/models/merge.js";
+import {
+ buildTelemetrySwitchFile,
+ parseTelemetrySwitchFile,
+ telemetryConfigPath,
+} from "../../../domain/models/telemetry-switch.js";
+import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js";
+import type { FileReader } from "../../../domain/ports/file-reader.js";
+import type { FileWriter } from "../../../domain/ports/file-writer.js";
+import type { Logger } from "../../../domain/ports/logger.js";
+import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
+import { getAiToolConfig } from "../../../domain/tools/registry.js";
+
+export interface TelemetryOffOptions {
+ readonly projectRoot: string;
+}
+
+export interface TelemetryOffResult {
+ readonly switchPath: string;
+ readonly switchChanged: boolean;
+ readonly removedFiles: readonly string[];
+ /** One line per `environment-variable`-activation tool, reminding the user AIDD never
+ * set — and so cannot unset — the variable itself. Unconditional: whether the tool is
+ * installed doesn't change that a shell might still have it exported. */
+ readonly manualUnsetReminders: readonly string[];
+}
+
+/** Sets the switch off, preserving the endpoint since the file is committed, and removes
+ * exactly the merge-file entries the manifest recorded — through the same
+ * `removeEntriesFromJson` `aidd clean` uses, never a second remover. Which section of which
+ * file to clean comes from that tool's `capabilities.telemetry`. */
+export class TelemetryOffUseCase {
+ constructor(
+ private readonly fs: FileReader & FileWriter,
+ private readonly manifestRepo: ManifestRepository,
+ private readonly logger: Logger
+ ) {}
+
+ async execute(options: TelemetryOffOptions): Promise {
+ const switchPath = telemetryConfigPath(options.projectRoot);
+ this.logger.info(`AIDD telemetry switch -> ${switchPath}`);
+ const switchChanged = await this.turnSwitchOff(switchPath);
+ const removedFiles = await this.removeTrackedTelemetryEntries(options.projectRoot);
+ const manualUnsetReminders = this.buildManualUnsetReminders();
+ return { switchPath, switchChanged, removedFiles, manualUnsetReminders };
+ }
+
+ private buildManualUnsetReminders(): string[] {
+ const reminders: string[] = [];
+ for (const toolId of AI_TOOL_IDS) {
+ const { telemetry, displayName } = getAiToolConfig(toolId);
+ if (telemetry.kind !== "environment-variable") continue;
+ reminders.push(
+ `${displayName}: if you exported ${telemetry.variable} yourself, unset it by hand.`
+ );
+ }
+ return reminders;
+ }
+
+ private async turnSwitchOff(switchPath: string): Promise {
+ if (!(await this.fs.fileExists(switchPath))) {
+ this.logger.info("AIDD telemetry: already off, unchanged.");
+ return false;
+ }
+ const raw = await this.fs.readFile(switchPath);
+ const current = parseTelemetrySwitchFile(raw);
+ if (current?.enabled !== true) {
+ this.logger.info("AIDD telemetry: already off, unchanged.");
+ return false;
+ }
+ const next = buildTelemetrySwitchFile(raw, { enabled: false, endpoint: current.endpoint });
+ await this.fs.writeFile(switchPath, next);
+ this.logger.info("AIDD telemetry: off.");
+ return true;
+ }
+
+ private async removeTrackedTelemetryEntries(projectRoot: string): Promise {
+ const manifest = await this.manifestRepo.load();
+ if (manifest === null) return [];
+ const removed: string[] = [];
+ let touched = false;
+ for (const toolId of AI_TOOL_IDS) {
+ if (!manifest.hasTool(toolId)) continue;
+ const cleaned = await this.removeToolEntries(manifest, toolId, projectRoot, removed);
+ touched = touched || cleaned;
+ }
+ if (touched) await this.manifestRepo.save(manifest);
+ return removed;
+ }
+
+ private async removeToolEntries(
+ manifest: Manifest,
+ toolId: AiToolId,
+ projectRoot: string,
+ removed: string[]
+ ): Promise {
+ const { telemetry: activation } = getAiToolConfig(toolId);
+ if (activation.kind !== "settings-file") return false;
+ const entries = manifest
+ .getMergeFiles(toolId)
+ .filter((m) => m.sectionKey === activation.sectionKey);
+ if (entries.length === 0) return false;
+ for (const entry of entries)
+ removed.push(...(await this.cleanEntry(toolId, projectRoot, entry)));
+ this.untrackEntries(manifest, toolId, entries);
+ return true;
+ }
+
+ private async cleanEntry(
+ toolId: AiToolId,
+ projectRoot: string,
+ entry: MergeFileEntry
+ ): Promise {
+ const fullPath = join(projectRoot, entry.relativePath);
+ this.logger.info(`${toolId} telemetry -> ${fullPath}`);
+ if (!(await this.fs.fileExists(fullPath))) {
+ // Not necessarily "already deleted by hand": a --scope user entry is a `..`-prefixed
+ // traversal from projectRoot (inherited caveat from phase 2), so it resolves wrong
+ // if the project directory moved — leaving the real file still exporting, with no
+ // manifest record left to undo it. Untracking proceeds regardless (repeating a
+ // resolution that can't succeed on every future `off` helps nobody), but this must
+ // never be silent.
+ this.logger.warn(
+ `Tracked telemetry entry not found at ${fullPath} — nothing removed there. ` +
+ "If this project directory moved, check that path (or the real one) by hand."
+ );
+ return [];
+ }
+ const content = await this.fs.readFile(fullPath);
+ const cleaned = removeEntriesFromJson(content, entry.sectionKey, Object.keys(entry.entries));
+ if (isMergeContentEmpty(cleaned, entry.sectionKey)) {
+ await this.fs.deleteFile(fullPath);
+ await this.fs.deleteEmptyDirectories(dirname(fullPath));
+ } else {
+ await this.fs.writeFile(fullPath, cleaned);
+ }
+ return [fullPath];
+ }
+
+ private untrackEntries(
+ manifest: Manifest,
+ toolId: AiToolId,
+ removedEntries: readonly MergeFileEntry[]
+ ): void {
+ const remaining = manifest
+ .getMergeFiles(toolId)
+ .filter(
+ (m) =>
+ !removedEntries.some(
+ (r) => r.relativePath === m.relativePath && r.sectionKey === m.sectionKey
+ )
+ );
+ manifest.updateToolMergeFiles(toolId, remaining);
+ }
+}
diff --git a/cli/src/application/use-cases/telemetry/telemetry-on-use-case.ts b/cli/src/application/use-cases/telemetry/telemetry-on-use-case.ts
new file mode 100644
index 000000000..28f7ebdcc
--- /dev/null
+++ b/cli/src/application/use-cases/telemetry/telemetry-on-use-case.ts
@@ -0,0 +1,227 @@
+import type {
+ TelemetryActivation,
+ TelemetryScope,
+ TelemetrySettingsFileActivation,
+} from "../../../domain/capabilities/telemetry-capability.js";
+import {
+ InvalidTelemetryEndpointError,
+ MissingTelemetryEndpointError,
+} from "../../../domain/errors.js";
+import type { Manifest } from "../../../domain/models/manifest.js";
+import {
+ buildTelemetrySwitchFile,
+ isValidTelemetryEndpoint,
+ parseTelemetrySwitchFile,
+ type TelemetrySwitch,
+ telemetryConfigPath,
+} from "../../../domain/models/telemetry-switch.js";
+import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js";
+import type { FileReader } from "../../../domain/ports/file-reader.js";
+import type { FileWriter } from "../../../domain/ports/file-writer.js";
+import type { Logger } from "../../../domain/ports/logger.js";
+import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js";
+import { getAiToolConfig } from "../../../domain/tools/registry.js";
+import { TelemetryProjectScopeRequiresYesError } from "../../errors.js";
+import type { EnableToolTelemetryUseCase } from "./enable-tool-telemetry-use-case.js";
+
+export type TelemetryToolStatus =
+ | "enabled"
+ | "not-installed"
+ | "not-yet-supported"
+ | "not-a-file"
+ | "cannot-enable";
+
+export interface TelemetryToolReport {
+ readonly tool: AiToolId;
+ readonly status: TelemetryToolStatus;
+ readonly detail: string;
+}
+
+export interface TelemetryOnOptions {
+ readonly projectRoot: string;
+ readonly homeDir: string;
+ readonly endpoint: string | undefined;
+ readonly scope: TelemetryScope;
+ readonly confirmProjectScope: boolean;
+}
+
+export interface TelemetryOnResult {
+ readonly switchPath: string;
+ readonly switchChanged: boolean;
+ readonly endpoint: string;
+ readonly toolReports: readonly TelemetryToolReport[];
+}
+
+/** The report for every activation kind AIDD cannot write to itself. Switches over `kind`,
+ * never over a tool name. */
+function staticReportFor(
+ toolId: AiToolId,
+ activation: Exclude
+): TelemetryToolReport {
+ const [status, detail] = staticStatusAndDetail(activation);
+ return { tool: toolId, status, detail };
+}
+
+function staticStatusAndDetail(
+ activation: Exclude
+): [TelemetryToolStatus, string] {
+ switch (activation.kind) {
+ case "environment-variable":
+ return [
+ "not-a-file",
+ `Not a file — export ${activation.variable}=${activation.value} yourself; ` +
+ "AIDD does not set environment variables.",
+ ];
+ case "planned":
+ return [
+ "not-yet-supported",
+ `Not yet supported by AIDD — tracked in ${activation.trackedIn}.`,
+ ];
+ case "external":
+ return ["cannot-enable", `${activation.reason} ${activation.remedy}`];
+ }
+}
+
+/** Writes the AIDD switch, then configures whichever installed tools can be configured,
+ * reporting each state rather than skipping it. Nothing is written when the project-scope
+ * guard refuses or no endpoint resolves. Every per-tool detail comes from that tool's
+ * `capabilities.telemetry`. */
+export class TelemetryOnUseCase {
+ constructor(
+ private readonly fs: FileReader & FileWriter,
+ private readonly manifestRepo: ManifestRepository,
+ private readonly enableToolTelemetry: EnableToolTelemetryUseCase,
+ private readonly logger: Logger,
+ private readonly deriveProjectId: (repoRoot: string) => Promise
+ ) {}
+
+ async execute(options: TelemetryOnOptions): Promise {
+ const switchPath = telemetryConfigPath(options.projectRoot);
+ this.logger.info(`AIDD telemetry switch -> ${switchPath}`);
+ this.guardTrackedScope(options);
+ this.noteUserScopeCaveat(options);
+
+ const existingRaw = await this.readIfExists(switchPath);
+ const existingSwitch = existingRaw !== null ? parseTelemetrySwitchFile(existingRaw) : null;
+ const endpoint = this.resolveEndpoint(options.endpoint, existingSwitch);
+
+ const switchChanged = await this.writeSwitch(switchPath, existingRaw, existingSwitch, endpoint);
+ const toolReports = await this.configureTools(options, endpoint);
+ return { switchPath, switchChanged, endpoint, toolReports };
+ }
+
+ // Fires whether or not the blocking tool is installed, so `--scope project` without
+ // `--yes` writes nothing at all.
+ private guardTrackedScope(options: TelemetryOnOptions): void {
+ if (options.confirmProjectScope) return;
+ for (const toolId of AI_TOOL_IDS) {
+ const { telemetry } = getAiToolConfig(toolId);
+ if (telemetry.kind !== "settings-file" || !telemetry.trackedScopes.includes(options.scope)) {
+ continue;
+ }
+ const activation = telemetry;
+ const wouldBePath = activation.resolveSettingsPath(
+ options.scope,
+ options.projectRoot,
+ options.homeDir
+ );
+ this.logger.info(`${toolId} telemetry (blocked, needs --yes) -> ${wouldBePath}`);
+ throw new TelemetryProjectScopeRequiresYesError(wouldBePath);
+ }
+ }
+
+ // MergeFileEntry.relativePath for --scope user is a `..`-prefixed traversal from
+ // projectRoot to the home directory, so it breaks if the project directory moves.
+ private noteUserScopeCaveat(options: TelemetryOnOptions): void {
+ if (options.scope !== "user") return;
+ this.logger.info(
+ "Note: --scope user records the undo path relative to this project root — " +
+ "if the project directory moves, `aidd telemetry off` may not find it."
+ );
+ }
+
+ private resolveEndpoint(
+ flagEndpoint: string | undefined,
+ existing: TelemetrySwitch | null
+ ): string {
+ const endpoint = flagEndpoint?.trim() || existing?.endpoint;
+ if (!endpoint) throw new MissingTelemetryEndpointError();
+ if (!isValidTelemetryEndpoint(endpoint)) throw new InvalidTelemetryEndpointError(endpoint);
+ return endpoint;
+ }
+
+ private async readIfExists(path: string): Promise {
+ return (await this.fs.fileExists(path)) ? await this.fs.readFile(path) : null;
+ }
+
+ private async writeSwitch(
+ switchPath: string,
+ existingRaw: string | null,
+ existingSwitch: TelemetrySwitch | null,
+ endpoint: string
+ ): Promise {
+ if (existingSwitch?.enabled === true && existingSwitch.endpoint === endpoint) {
+ this.logger.info("AIDD telemetry: already on, unchanged.");
+ return false;
+ }
+ const next = buildTelemetrySwitchFile(existingRaw, { enabled: true, endpoint });
+ await this.fs.writeFile(switchPath, next);
+ this.logger.info("AIDD telemetry: on.");
+ return true;
+ }
+
+ private async configureTools(
+ options: TelemetryOnOptions,
+ endpoint: string
+ ): Promise {
+ const manifest = await this.manifestRepo.load();
+ const reports: TelemetryToolReport[] = [];
+ for (const toolId of AI_TOOL_IDS) {
+ reports.push(await this.configureTool(toolId, manifest, options, endpoint));
+ }
+ return reports;
+ }
+
+ private async configureTool(
+ toolId: AiToolId,
+ manifest: Manifest | null,
+ options: TelemetryOnOptions,
+ endpoint: string
+ ): Promise {
+ if (!manifest?.hasTool(toolId)) {
+ return { tool: toolId, status: "not-installed", detail: "Not installed — skipped." };
+ }
+ return this.reportForActivation(toolId, getAiToolConfig(toolId).telemetry, options, endpoint);
+ }
+
+ private async reportForActivation(
+ toolId: AiToolId,
+ activation: TelemetryActivation,
+ options: TelemetryOnOptions,
+ endpoint: string
+ ): Promise {
+ if (activation.kind === "settings-file") {
+ return this.enableSettingsFileTool(toolId, activation, options, endpoint);
+ }
+ return staticReportFor(toolId, activation);
+ }
+
+ private async enableSettingsFileTool(
+ toolId: AiToolId,
+ activation: TelemetrySettingsFileActivation,
+ options: TelemetryOnOptions,
+ endpoint: string
+ ): Promise {
+ const projectId = await this.deriveProjectId(options.projectRoot);
+ const result = await this.enableToolTelemetry.execute({
+ toolId,
+ activation,
+ projectRoot: options.projectRoot,
+ homeDir: options.homeDir,
+ endpoint,
+ projectId,
+ scope: options.scope,
+ });
+ return { tool: toolId, status: "enabled", detail: result.settingsPath };
+ }
+}
diff --git a/cli/src/cli.ts b/cli/src/cli.ts
index 920493027..d38e0a620 100644
--- a/cli/src/cli.ts
+++ b/cli/src/cli.ts
@@ -14,6 +14,7 @@ import { registerRestoreCommand } from "./application/commands/restore.js";
import { registerSelfUpdateCommand } from "./application/commands/self-update.js";
import { registerSetupCommand } from "./application/commands/setup.js";
import { registerStatusCommand } from "./application/commands/status.js";
+import { registerTelemetryCommand } from "./application/commands/telemetry.js";
import { registerUpdateCommand } from "./application/commands/update.js";
import { CLIOutput } from "./application/output.js";
import { CurrentVersionAdapter } from "./infrastructure/adapters/current-version-adapter.js";
@@ -46,6 +47,7 @@ registerRestoreCommand(program);
registerUpdateCommand(program);
registerDoctorCommand(program);
registerCleanCommand(program);
+registerTelemetryCommand(program);
registerSelfUpdateCommand(program);
// Commands already paying for network I/O: piggyback the update-check refresh on them.
diff --git a/cli/src/domain/capabilities/marketplace-entry.ts b/cli/src/domain/capabilities/marketplace-entry.ts
index 6d480988e..52dd8d48b 100644
--- a/cli/src/domain/capabilities/marketplace-entry.ts
+++ b/cli/src/domain/capabilities/marketplace-entry.ts
@@ -1,12 +1,8 @@
import type { MarketplaceSettingsEntry, MarketplaceSettingsInput } from "./plugins-capability.js";
-/**
- * Shared toEntry implementation for tools that use the Claude Code marketplace schema:
- * { source: { source: "github"|"directory", repo/path: "..." }, version? }
- *
- * Used by: claude, cursor, codex
- */
-export function buildClaudeStyleMarketplaceEntry(
+/** `{ source: { source: "github"|"directory", repo/path: "..." }, version? }` — the entry
+ * shape every tool accepts unless it declares its own. */
+export function buildDefaultMarketplaceEntry(
input: MarketplaceSettingsInput
): MarketplaceSettingsEntry | null {
const { name, source, version } = input;
diff --git a/cli/src/domain/capabilities/plugins-capability.ts b/cli/src/domain/capabilities/plugins-capability.ts
index 4a2dc37a7..f4f6760c4 100644
--- a/cli/src/domain/capabilities/plugins-capability.ts
+++ b/cli/src/domain/capabilities/plugins-capability.ts
@@ -45,7 +45,7 @@ export interface MarketplaceSettings {
* `NativePluginActivator` in the marketplace-sync registry.
*/
export interface NativeActivation {
- binary: "codex" | "copilot";
+ binary: "claude" | "codex" | "copilot";
}
export interface NativePluginsParams {
@@ -56,8 +56,21 @@ export interface NativePluginsParams {
mcpRelativePath?: string;
hooksRelativePath?: string;
hooksContentFormat?: HooksContentFormat;
- acceptsHooks?: boolean;
+ /**
+ * Where a delivered hook actually lands. `"plugin"` (default): under this
+ * capability's own plugin directory, at `hooksRelativePath` — read by nothing for
+ * a tool whose hooks only fire from project scope. `"project"`: merged into the
+ * project's own hooks file instead (see `mergeCursorProjectHooksJson`), the
+ * destination measured to actually fire. Declared per capability, not guessed
+ * per tool, so a tool proven to need it is the only one that sets it.
+ */
+ hooksDestination?: "plugin" | "project";
acceptsMcp?: boolean;
+ /**
+ * The variable this tool expands to the installed plugin's directory, as
+ * written in a hook or MCP command. Absent means nothing is substituted.
+ */
+ pluginRootToken?: string;
marketplaceSettings?: MarketplaceSettings;
/** Enables native CLI-driven plugin activation (e.g. Codex). See {@link NativeActivation}. */
nativeActivation?: NativeActivation;
@@ -80,16 +93,47 @@ export interface NativePluginsParams {
userPluginsDir?: (homedir: string) => string;
}
-export interface FlatPluginsParams {
+/**
+ * Flat mode's own hooks declaration. Unlike native mode's `hooksRelativePath` (a file
+ * beside a manifest a merge writes to), a flat-mode hook lands as files an extension
+ * loader scans a directory for — `flatHooksDir` names that directory, relative to the
+ * project root. See {@link HooksSupport} for the shape of the "no" case.
+ */
+export type FlatHooksSupport =
+ | { acceptsHooks: true; flatHooksDir: string }
+ | { acceptsHooks: false; hooksUnsupportedReason: string };
+
+export type FlatPluginsParams = {
mode: "flat";
flatNamespacePrefix: string;
-}
+} & FlatHooksSupport;
export interface UnsupportedPluginsParams {
mode: "unsupported";
+ /** See {@link FlatPluginsParams.hooksUnsupportedReason}. */
+ hooksUnsupportedReason: string;
}
-type PluginsParams = NativePluginsParams | FlatPluginsParams | UnsupportedPluginsParams;
+/**
+ * Whether this tool runs the hooks a plugin ships. Stated, never defaulted: a tool nobody
+ * considered loses its hooks quietly when the field falls back to `false`, and one that
+ * runs none owes whoever installs a plugin a reason.
+ *
+ * `hooksTrustNotice` is the opposite case: the tool runs a delivered hook, but only once
+ * something outside the install grants it — a per-hook trust the tool itself gates and
+ * that a headless run never gets prompted for (measured on Codex: four clean `codex exec`
+ * sessions wrote no journal and said nothing, until `--dangerously-bypass-hook-trust` did).
+ * `null`/omitted for a tool that runs what it delivers with no such gate — told nothing,
+ * same as `hooksUnsupportedReason` for a tool that never runs hooks at all.
+ */
+export type HooksSupport =
+ | { acceptsHooks: true; hooksTrustNotice?: string }
+ | { acceptsHooks: false; hooksUnsupportedReason: string };
+
+type PluginsParams =
+ | (NativePluginsParams & HooksSupport)
+ | FlatPluginsParams
+ | UnsupportedPluginsParams;
export class PluginsCapability {
readonly mode: PluginsMode;
@@ -97,10 +141,20 @@ export class PluginsCapability {
readonly pluginManifestRelativePath: string | null;
readonly flatNamespacePrefix: string | null;
readonly acceptsHooks: boolean;
+ /** Why no hook is delivered, or `null` when they are. */
+ readonly hooksUnsupportedReason: string | null;
+ /** What still has to happen before a delivered hook actually runs, or `null` when
+ * nothing does. See {@link HooksSupport}. */
+ readonly hooksTrustNotice: string | null;
+ readonly pluginRootToken: string | null;
readonly acceptsMcp: boolean;
readonly mcpRelativePath: string;
readonly hooksRelativePath: string;
readonly hooksContentFormat: HooksContentFormat;
+ readonly hooksDestination: "plugin" | "project";
+ /** Where a flat-mode hook lands, relative to the project root, or `null` when this
+ * capability's `acceptsHooks` is `false`. See {@link FlatHooksSupport}. */
+ readonly flatHooksDir: string | null;
readonly marketplaceSettings: MarketplaceSettings | null;
/** Native CLI-driven plugin activation declaration, or `null` when not applicable. */
readonly nativeActivation: NativeActivation | null;
@@ -132,23 +186,50 @@ export class PluginsCapability {
this.pluginsDir = params.pluginsDir;
this.pluginManifestRelativePath = params.pluginManifestRelativePath;
this.flatNamespacePrefix = null;
- this.acceptsHooks = params.acceptsHooks ?? false;
+ this.acceptsHooks = params.acceptsHooks;
+ this.hooksUnsupportedReason = params.acceptsHooks ? null : params.hooksUnsupportedReason;
+ this.hooksTrustNotice = params.acceptsHooks ? (params.hooksTrustNotice ?? null) : null;
+ this.pluginRootToken = params.pluginRootToken ?? null;
this.acceptsMcp = params.acceptsMcp ?? false;
this.mcpRelativePath = params.mcpRelativePath ?? DEFAULT_MCP_PATH;
this.hooksRelativePath = params.hooksRelativePath ?? DEFAULT_HOOKS_PATH;
this.hooksContentFormat = params.hooksContentFormat ?? DEFAULT_HOOKS_FORMAT;
+ this.hooksDestination = params.hooksDestination ?? "plugin";
+ this.flatHooksDir = null;
this.marketplaceSettings = params.marketplaceSettings ?? null;
this.nativeActivation = params.nativeActivation ?? null;
this._userPluginsDir = params.userPluginsDir;
+ } else if (params.mode === "flat") {
+ this.pluginsDir = null;
+ this.pluginManifestRelativePath = null;
+ this.flatNamespacePrefix = params.flatNamespacePrefix;
+ this.acceptsHooks = params.acceptsHooks;
+ this.hooksUnsupportedReason = params.acceptsHooks ? null : params.hooksUnsupportedReason;
+ this.flatHooksDir = params.acceptsHooks ? params.flatHooksDir : null;
+ this.hooksTrustNotice = null;
+ this.pluginRootToken = null;
+ this.acceptsMcp = false;
+ this.mcpRelativePath = DEFAULT_MCP_PATH;
+ this.hooksRelativePath = DEFAULT_HOOKS_PATH;
+ this.hooksContentFormat = DEFAULT_HOOKS_FORMAT;
+ this.hooksDestination = "plugin";
+ this.marketplaceSettings = null;
+ this.nativeActivation = null;
+ this._userPluginsDir = undefined;
} else {
this.pluginsDir = null;
this.pluginManifestRelativePath = null;
- this.flatNamespacePrefix = params.mode === "flat" ? params.flatNamespacePrefix : null;
+ this.flatNamespacePrefix = null;
this.acceptsHooks = false;
+ this.hooksUnsupportedReason = params.hooksUnsupportedReason;
+ this.flatHooksDir = null;
+ this.hooksTrustNotice = null;
+ this.pluginRootToken = null;
this.acceptsMcp = false;
this.mcpRelativePath = DEFAULT_MCP_PATH;
this.hooksRelativePath = DEFAULT_HOOKS_PATH;
this.hooksContentFormat = DEFAULT_HOOKS_FORMAT;
+ this.hooksDestination = "plugin";
this.marketplaceSettings = null;
this.nativeActivation = null;
this._userPluginsDir = undefined;
diff --git a/cli/src/domain/capabilities/telemetry-capability.ts b/cli/src/domain/capabilities/telemetry-capability.ts
new file mode 100644
index 000000000..ea2cf4fb3
--- /dev/null
+++ b/cli/src/domain/capabilities/telemetry-capability.ts
@@ -0,0 +1,143 @@
+import type { MergeStrategy } from "../models/merge.js";
+import type { TelemetrySessionMeasure } from "../models/telemetry-sink-record.js";
+
+/**
+ * Where the enabled export lands, and who is affected:
+ * - `local` — machine-local, not git-tracked (default)
+ * - `project` — git-tracked, everyone who clones the project inherits it
+ * - `user` — this machine, every project
+ */
+export const TELEMETRY_SCOPES = ["local", "project", "user"] as const;
+export type TelemetryScope = (typeof TELEMETRY_SCOPES)[number];
+export const DEFAULT_TELEMETRY_SCOPE: TelemetryScope = "local";
+
+/** The tool writes telemetry config into a settings file AIDD merges into, so `aidd clean`
+ * can undo it. `resolveSettingsPath` and `buildEnv` are pure, keeping the calling use-case
+ * free of I/O and of this tool's on-disk shape. `trackedScopes` names the scopes that write
+ * a git-tracked file, which need `--yes`. */
+export interface TelemetrySettingsFileActivation {
+ readonly kind: "settings-file";
+ readonly sectionKey: string;
+ readonly mergeStrategy: MergeStrategy;
+ readonly scopes: readonly TelemetryScope[];
+ readonly defaultScope: TelemetryScope;
+ readonly trackedScopes: readonly TelemetryScope[];
+ resolveSettingsPath(scope: TelemetryScope, projectRoot: string, homeDir: string): string;
+ buildEnv(endpoint: string | undefined, projectId: string): Readonly>;
+ /** Printed once, after a successful write — a caveat specific to this tool's export. */
+ readonly postEnableNotice?: string;
+}
+
+/** The tool reads an environment variable AIDD does not, and will not, set on the user's
+ * behalf — exporting env vars into someone's shell is out of scope for a project-local CLI. */
+export interface TelemetryEnvironmentVariableActivation {
+ readonly kind: "environment-variable";
+ readonly variable: string;
+ readonly value: string;
+}
+
+/** AIDD has no writer for this tool's telemetry config yet. */
+export interface TelemetryPlannedActivation {
+ readonly kind: "planned";
+ readonly trackedIn: string;
+}
+
+/** Enabling this tool's telemetry requires an action AIDD cannot perform (a dashboard
+ * toggle, a plan tier, ...). */
+export interface TelemetryExternalActivation {
+ readonly kind: "external";
+ readonly reason: string;
+ readonly remedy: string;
+}
+
+export type TelemetryActivation =
+ | TelemetrySettingsFileActivation
+ | TelemetryEnvironmentVariableActivation
+ | TelemetryPlannedActivation
+ | TelemetryExternalActivation;
+
+/** What a route was **measured to supply**, not what it might. Three facts, because a
+ * consumer reading a report has to tell four things apart that all look like a missing
+ * number: a tool that supplies no counters at all, one that supplies counters but no
+ * amount, one that supplies an amount, and one whose figures carry the step the tool
+ * itself named.
+ *
+ * Declared per route rather than per tool, because the answer differs by route on the
+ * first tool measured: Claude Code carries an amount on its export and not on its local
+ * read, and states its own step on the local read and not on the export.
+ *
+ * Every field is required. A default here would be a capability nobody measured, quietly
+ * asserted for a tool nobody looked at. */
+export interface TelemetryRouteSupply {
+ /** The four token counters. */
+ readonly tokenCounters: boolean;
+ /** A figure denominated in currency. Never a credit, a premium request, or a zero whose
+ * denomination was never established. */
+ readonly amount: boolean;
+ /** The tool names the running step itself, on the record. An interval derived from the
+ * run journal is not this — that is the framework's inference, not the tool's statement. */
+ readonly toolStatedStep: boolean;
+}
+
+/** What a tool's OTLP export carries, measured by hand one session per tool, never taken
+ * from documentation. The sink mapper reads this and nothing else to resolve which tool
+ * sent a payload; it never branches on `toolId`. */
+export interface TelemetryExportDeclared {
+ readonly kind: "declared";
+ readonly identityAttribute: string;
+ readonly turnAttribute?: string;
+ readonly sessionMeasures?: readonly TelemetrySessionMeasure[];
+ readonly supplies: TelemetryRouteSupply;
+}
+
+/** No session has been captured for this tool's export yet — declared rather than guessed. */
+export interface TelemetryExportUnmeasured {
+ readonly kind: "unmeasured";
+}
+
+export type TelemetryExport = TelemetryExportDeclared | TelemetryExportUnmeasured;
+
+/** Where a tool's own transcript files live, and how to recognise the one file (or files)
+ * for a session — declared per tool since only the tool knows its own directory layout, so
+ * the adapter that opens files never encodes one itself. `matches` receives the candidate's
+ * path already relative to `root`, not its basename: Claude Code's subagent transcripts live
+ * one directory per session (`/subagents/*.jsonl`), distinguishable only by that
+ * nesting, not by file name alone. */
+export interface TranscriptLocation {
+ root(homeDir: string): string;
+ matches(relativePath: string, sessionId: string): boolean;
+}
+
+/** This tool's own file(s) can be read for a session's counters without exporting anything
+ * and without a process running. Read through `ReadLocalCostUseCase`, which asks every
+ * tool's declaration and never branches on `toolId`. `transcript` is optional: a tool read
+ * by another means entirely (OpenCode shells out to its own CLI instead of opening a file)
+ * declares `{ kind: "declared" }` with no transcript location at all. */
+export interface TelemetryLocalReadDeclared {
+ readonly kind: "declared";
+ readonly transcript?: TranscriptLocation;
+ readonly supplies: TelemetryRouteSupply;
+ /** A caveat that survives to the person reading the result, when what this tool can be
+ * read for is narrower than the others. Data rather than a source comment, because a
+ * comment reaches nobody downstream: a consumer would otherwise see figures with no
+ * journal entry beside them and be left to guess why. */
+ readonly limitation?: string;
+}
+
+/** No reader has been wired for this tool yet in this codebase — a fact about current
+ * coverage, not a claim that the tool's file could never be read. */
+export interface TelemetryLocalReadUnmeasured {
+ readonly kind: "unmeasured";
+}
+
+/** This tool's own file cannot yield what a local read needs, established by probe rather
+ * than assumed from an empty result. */
+export interface TelemetryLocalReadUnsupported {
+ readonly kind: "unsupported";
+ readonly reason: string;
+}
+
+export type TelemetryLocalRead =
+ | TelemetryLocalReadDeclared
+ | TelemetryLocalReadUnmeasured
+ | TelemetryLocalReadUnsupported;
diff --git a/cli/src/domain/errors.ts b/cli/src/domain/errors.ts
index d84c9ffda..d4f671ec2 100644
--- a/cli/src/domain/errors.ts
+++ b/cli/src/domain/errors.ts
@@ -449,9 +449,60 @@ export class EmptyMarketplaceCacheNameError extends Error {
}
}
+export class MissingTelemetryEndpointError extends Error {
+ constructor() {
+ super(
+ "No OTEL export endpoint given. Telemetry cannot be enabled without one — " +
+ "there is no default, not even localhost."
+ );
+ this.name = "MissingTelemetryEndpointError";
+ }
+}
+
+export class InvalidTelemetryEndpointError extends Error {
+ constructor(value: string) {
+ super(`Invalid telemetry endpoint '${value}' — expected an http(s) URL.`);
+ this.name = "InvalidTelemetryEndpointError";
+ }
+}
+
export class NativePluginCliError extends Error {
constructor(message: string) {
super(message);
this.name = "NativePluginCliError";
}
}
+
+export class UnknownTelemetrySinkSchemaVersionError extends Error {
+ constructor(version: unknown) {
+ super(
+ `Unknown telemetry sink schema version '${String(version)}' — refusing to guess its shape.`
+ );
+ this.name = "UnknownTelemetrySinkSchemaVersionError";
+ }
+}
+
+/** A genuine `opencode export` failure — a non-zero exit not explained by "no such
+ * session", or the command exceeding its timeout. An absent binary or an unknown session
+ * are not this: those mean the machine simply holds no OpenCode data, and the reader
+ * resolves to an empty array for them instead of throwing. */
+export class OpencodeExportError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "OpencodeExportError";
+ }
+}
+
+export class InvalidReportDayError extends Error {
+ constructor(flag: string, value: string) {
+ super(`Invalid ${flag} '${value}'. Expected a UTC day, as YYYY-MM-DD.`);
+ this.name = "InvalidReportDayError";
+ }
+}
+
+export class InvalidReportSpanError extends Error {
+ constructor(value: string, maxDays: number) {
+ super(`Invalid --days '${value}'. Expected an integer between 1 and ${maxDays}.`);
+ this.name = "InvalidReportSpanError";
+ }
+}
diff --git a/cli/src/domain/formats/claude-code-transcript.ts b/cli/src/domain/formats/claude-code-transcript.ts
new file mode 100644
index 000000000..e480c851b
--- /dev/null
+++ b/cli/src/domain/formats/claude-code-transcript.ts
@@ -0,0 +1,216 @@
+import { sep } from "node:path";
+import type { TranscriptLocation } from "../capabilities/telemetry-capability.js";
+import type {
+ LocalCostCandidateRecord,
+ TranscriptLineAccumulator,
+} from "../ports/session-cost-reader.js";
+
+// Measured 2026-08-20 against two real files: a main transcript line from
+// ~/.claude/projects/*/*.jsonl (Claude Code 2.1.229) and a subagent's own line from
+// ~/.claude/projects/*//subagents/agent-*.jsonl (2.1.232). If Claude Code moves
+// any of these field names, tests/domain/formats/claude-code-transcript.unit.test.ts turns
+// red against the captured fixture before a zero could be stored in the moved field's place.
+//
+// A subagent's own messages are never inline in the main transcript — every `isSidechain:
+// true` line measured lives only in its own `/subagents/agent-*.jsonl` file,
+// which is why the adapter's `TranscriptLocation` below matches both layouts.
+const VENDOR_FIELD = "sessionId";
+const TURN_FIELD = "requestId";
+
+interface ClaudeUsage {
+ readonly input_tokens?: unknown;
+ readonly cache_creation_input_tokens?: unknown;
+ readonly cache_read_input_tokens?: unknown;
+ readonly output_tokens?: unknown;
+}
+
+interface ClaudeTranscriptLine {
+ readonly type?: unknown;
+ readonly sessionId?: unknown;
+ readonly requestId?: unknown;
+ readonly isSidechain?: unknown;
+ readonly timestamp?: unknown;
+ readonly effort?: unknown;
+ readonly attributionAgent?: unknown;
+ readonly attributionSkill?: unknown;
+ readonly attributionPlugin?: unknown;
+ readonly message?: {
+ readonly model?: unknown;
+ readonly id?: unknown;
+ readonly usage?: ClaudeUsage;
+ };
+}
+
+interface ClaudeCounters {
+ readonly input_tokens: number;
+ readonly cache_creation_input_tokens: number;
+ readonly cache_read_input_tokens: number;
+ readonly output_tokens: number;
+}
+
+function asNumber(value: unknown): number | undefined {
+ return typeof value === "number" ? value : undefined;
+}
+
+function asString(value: unknown): string | undefined {
+ return typeof value === "string" ? value : undefined;
+}
+
+/** All four or none: a partial `usage` — a truncated final line, or a shape this file has
+ * not been taught — yields no record rather than one with a missing counter read as zero. */
+function readCounters(usage: ClaudeUsage | undefined): ClaudeCounters | null {
+ const input = asNumber(usage?.input_tokens);
+ const cacheCreation = asNumber(usage?.cache_creation_input_tokens);
+ const cacheRead = asNumber(usage?.cache_read_input_tokens);
+ const output = asNumber(usage?.output_tokens);
+ if (input === undefined || cacheCreation === undefined) return null;
+ if (cacheRead === undefined || output === undefined) return null;
+ return {
+ input_tokens: input,
+ cache_creation_input_tokens: cacheCreation,
+ cache_read_input_tokens: cacheRead,
+ output_tokens: output,
+ };
+}
+
+function buildIdentity(
+ line: ClaudeTranscriptLine,
+ vendorId: string
+): Pick {
+ const turnId = asString(line.requestId);
+ return {
+ vendor_id: vendorId,
+ vendor_field: VENDOR_FIELD,
+ ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}),
+ };
+}
+
+// The export path sets `agent_name` for a subagent's own request (see
+// otlp-logs-claude-code-subagent.json); matching that here is what keeps a consumer from
+// being able to tell a local-read subagent record from an exported one by anything but
+// `provenance`.
+// `attributionSkill` is exact and unflagged, per message, on the same line as `usage` —
+// measured 2026-08-20 against 40 real transcripts (2267 attributed messages, 25 distinct
+// skills). It arrived around Claude Code 2.1.220 and is omitted, never nulled, when no
+// skill is running; a version that predates the field omits it identically. Nothing on the
+// line separates those two cases, so its absence here yields no `step` at all, leaving
+// attribution to fall back to a run-journal interval (or unattributed) rather than
+// asserting "no skill ran". `attributionPlugin` is read alongside it, and only alongside
+// it — a plugin name with no skill name is not a fact this line can state.
+function buildOptionalFields(
+ line: ClaudeTranscriptLine
+): Pick<
+ LocalCostCandidateRecord,
+ "model" | "effort" | "event_timestamp" | "agent_name" | "step" | "step_plugin"
+> {
+ const model = asString(line.message?.model);
+ const effort = asString(line.effort);
+ const timestamp = asString(line.timestamp);
+ const agentName = line.isSidechain === true ? asString(line.attributionAgent) : undefined;
+ const step = asString(line.attributionSkill);
+ const stepPlugin = step !== undefined ? asString(line.attributionPlugin) : undefined;
+ return {
+ ...(model !== undefined ? { model } : {}),
+ ...(effort !== undefined ? { effort } : {}),
+ ...(timestamp !== undefined ? { event_timestamp: timestamp } : {}),
+ ...(agentName !== undefined ? { agent_name: agentName } : {}),
+ ...(step !== undefined ? { step } : {}),
+ ...(stepPlugin !== undefined ? { step_plugin: stepPlugin } : {}),
+ };
+}
+
+function buildRecord(
+ line: ClaudeTranscriptLine,
+ vendorId: string,
+ counters: ClaudeCounters
+): LocalCostCandidateRecord {
+ return {
+ kind: "request",
+ ...buildIdentity(line, vendorId),
+ ...buildOptionalFields(line),
+ input_tokens: counters.input_tokens,
+ output_tokens: counters.output_tokens,
+ cache_read_tokens: counters.cache_read_input_tokens,
+ cache_creation_tokens: counters.cache_creation_input_tokens,
+ };
+}
+
+/** One parsed JSONL line, keyed by `message.id` — the identifier that ties together the
+ * separate log lines one API call can produce. A real capture showed one assistant call
+ * logged as two lines (a `thinking` content block, then a `tool_use` block) sharing one
+ * `message.id` and one `requestId`, each carrying the same `usage`. Mapping every such line
+ * to its own record would count that single call's tokens twice. */
+function parseAssistantLine(
+ line: string
+): { readonly dedupeKey: string; readonly record: LocalCostCandidateRecord } | null {
+ const trimmed = line.trim();
+ if (!trimmed) return null;
+ let parsed: ClaudeTranscriptLine;
+ try {
+ parsed = JSON.parse(trimmed) as ClaudeTranscriptLine;
+ } catch {
+ return null;
+ }
+ if (parsed.type !== "assistant") return null;
+ const vendorId = asString(parsed.sessionId);
+ if (vendorId === undefined) return null;
+ const counters = readCounters(parsed.message?.usage);
+ if (!counters) return null;
+ const dedupeKey = asString(parsed.message?.id) ?? asString(parsed.requestId) ?? trimmed;
+ return { dedupeKey, record: buildRecord(parsed, vendorId, counters) };
+}
+
+class ClaudeCodeTranscriptAccumulator implements TranscriptLineAccumulator {
+ private readonly seen = new Set