diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 2cb0d843e..3ef814e47 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -234,25 +234,31 @@ A branch is declared only when the command declared it on that branch. Both read | Condition | Test | | --- | --- | -| Claude's own payload | `session_id` = `CLAUDE_CODE_SESSION_ID`, `transcript_path` under a `projects` directory | +| a tool's own payload | Claude Code: `session_id` = `CLAUDE_CODE_SESSION_ID`, `transcript_path` under a `projects` directory and no Codex session file. Codex: `session_id` set, `transcript_path` a Codex session file (`…/sessions/YYYY/MM/DD/rollout-…`), whatever `CLAUDE_CODE_SESSION_ID` says, since a Codex started inside Claude Code inherits it (`hooks/lib/tool.cjs`, fixture `hookTool`) | | clone measured | consenting per [Consent](#consent), `AIDD_TELEMETRY` not `0` | -| person present | `CLAUDE_CODE_SESSION_ATTENDED=1` and `CLAUDE_CODE_ENTRYPOINT` is `cli` or `claude-desktop` (surfaces seen showing a hook message; an IDE extension is not asked, VS Code seen showing none) | +| person present | Claude Code: `CLAUDE_CODE_SESSION_ATTENDED=1` and `CLAUDE_CODE_ENTRYPOINT` is `cli` or `claude-desktop` (surfaces seen showing a hook message; an IDE extension is not asked, VS Code seen showing none). Codex: see below | | work undeclared | working branch, no declaration for session or branch | | CLI usable | `aidd` on `PATH` and knows `telemetry task` | - Anything else passes. A crash exits 0: the hook never fails a prompt. +- Codex shows a block's reason in the TUI only (`codex exec` prints none; the desktop app shows it too, observed, and the IDE extension is unobserved), and hands a hook nothing that names its surface. The session file's first line does. A person is present only when all hold, anything else (a field missing, another value, an unreadable file, a first line longer than the 1 MiB read) being nobody to answer: + - the first line of `transcript_path`, read up to 1 MiB, is a `session_meta` with `originator` `codex-tui`. Its `source` is `cli` on a bare config and `vscode` when plugins run the TUI through the app server, so `source` is not read, except that an object `source` (a sub-agent's own file) is refused. Codex writes it before it hands a hook the path; seen up to 48 KiB, base instructions included; + - the payload has no `agent_id`: a sub-agent's spawn message reaches the hook with its root's `session_id`, and nobody typed it; + - `permission_mode` is `default`: `codex exec` forces approvals off (`bypassPermissions`), and a TUI thread resumed by `codex exec` keeps its first line. - Re-checked on every prompt: each prompt on an undeclared working branch is blocked until the branch or the session is declared (`--none` included). A declared branch is never asked again, unless a copy or rename onto it changes its keys; switching to an undeclared one asks. - Why every prompt and not once: the gate exists so that no token of the work is spent before it has a task ([spec](../tasks/2026_10/2026_10_09_deterministic-tokens/spec.md), [target](../tasks/2026_10/2026_10_09_deterministic-tokens/context/target.md)). "Once per working branch" there meant answered once and remembered; letting the next prompt through would spend tokens unattributed. Answering never blocks and never reaches the model: - `! aidd telemetry task ...` in the terminal fires no `UserPromptSubmit`. - `aidd telemetry task ...` typed as a prompt runs through the CLI with `--by hook-intercept`. Block reason: one line, what was declared, "Send your prompt again." +- The session it binds: `AIDD_TELEMETRY_SESSION_ID`, else `CLAUDE_CODE_SESSION_ID`, read by the composition root (`runtime/wiring/telemetry.ts`). The Codex hook sets the first, to its payload's `session_id`, for the command it runs; nothing else does. A sub-agent's calls carry their root's session, so they take its declaration. +- Claude Code hides the typed declaration from the transcript. Codex was never seen to accept that, so its block keeps the prompt shown; it is still never sent to the model. - Grammar: a name, `--ticket `, `--none`, nothing else. Outside it nothing runs. - A one-line prompt starting like a declaration, only words and quoted strings, no `?`, `!`, comma or final full stop (`aidd telemetry task fix cart`) is blocked with the grammar and quoting hint, kept visible. Any other prompt starting with those words is ordinary. ## Carry -`/clear` and `/branch` (source `fork`) start a new session in the same process; the payload names no predecessor. The `SessionStart` hook (`hooks/session-start.cjs`) records every start. +`/clear` and `/branch` (source `fork`) start a new session in the same process; the payload names no predecessor. The `SessionStart` hook (`hooks/session-start.cjs`) records every Claude Code start. Codex gives a hook no process id, so for Codex it does nothing, not even the consent check: a Codex `/clear` or fork is a new session, asked again on an undeclared branch. ```mermaid sequenceDiagram @@ -316,7 +322,7 @@ stateDiagram-v2 | `on` | under the ledger lock: fresh token, key `2:` first, then open line (a key with no interval measures nothing; an interval its key does not name would be closed by the first hook). Key naming an open interval of this clone: no change. Otherwise closes this clone's stale open intervals at its time. Refuses a clone with no identity. Then, only once on, writes the person's identity when one was chosen | | `off` | under the same lock: close the clone's open token, then key `off`; key and interval change together, so an ingest never sees one without the other. Also closes the interval its key names at its path, whatever identity it reads there | | ingest | before judging any call, for every open interval, closes at observation time when the identity at its path changed or the live key is not `2:`. Nothing at the path: not closed (unmounted volume, folder moved and back); measured again when back. Unreadable config or `stat`: stays open, stores nothing | -| hooks | after the Claude-only guard, even headless: key not naming an open interval recorded for this clone's real path => append a close at hook time. Ends a manual `git config aidd.telemetry off` or `--unset` before the next prompt's calls. `AIDD_TELEMETRY=0`: does nothing | +| hooks | after the tool guard, even headless (`session-start.cjs` excepted for Codex, where it does nothing): key not naming an open interval recorded for this clone's real path => append a close at hook time. Ends a manual `git config aidd.telemetry off` or `--unset` before the next prompt's calls. `AIDD_TELEMETRY=0`: does nothing | | `forget --yes` | removes the log with the ledger; unsets the key in every recorded clone | **Consenting, for hooks and `task`.** All three: key is `2:`; log holds an open interval with it; that interval's clone is this one, by common-dir real path and by `dev`, `ino`, `birthtimeMs`, normalised as the CLI does (inode as text, missing birth as `0`, any other kept even if equal to change time, no inode = no identity; fixture `cloneIdentity` and `hookConsent` cases). Not consenting: a hand-set key (`2`, made-up token), a `cp -R` copy, a copy moved onto a deleted original's path, a clone remade at a path, a moved clone. There: nothing asked, nothing blocked, nothing closed that is not this clone's; run `aidd telemetry on`. @@ -328,7 +334,7 @@ stateDiagram-v2 - A clone that never opted in: nothing stored, asked or blocked. Nothing is kept of its calls in `left-out.txt` either; `roots.json` may name its directory, as for any directory read. - Key `off`, from `off` or by hand: `status` and `task` say measurement was turned off in this clone and that `aidd telemetry on` measures it again; a damaged log or an unidentified clone reads unreadable instead, since `on` would refuse. Any other key that grants nothing reads as never opted in. Ingest still counts its calls under `no-consent`. -**`on` also:** removes the old `telemetry` block from `.aidd/config.json` byte-for-byte otherwise, deleting the file only if that block was all (says the change may need committing); removes an earlier measurement's leftovers in the repository; records its clone for `forget`; warns when Claude Code's transcript retention is short; ends with the next step, declaring a task, which the hooks ask for in Claude Code. Does not reset offsets. +**`on` also:** removes the old `telemetry` block from `.aidd/config.json` byte-for-byte otherwise, deleting the file only if that block was all (says the change may need committing); removes an earlier measurement's leftovers in the repository; records its clone for `forget`; warns when Claude Code's transcript retention is short; ends with the next step, declaring a task, which the hooks ask for in Claude Code and the Codex TUI. Does not reset offsets. **`forget --yes`** unsets `aidd.telemetry` and branch task keys in every clone in `roots.json` or `consents.jsonl` that still exists. Names only gone clones that had consented. A clone that never ran `on` is recorded only if a call from it was read after some clone opted in, so a hand-set key there may stay. @@ -355,9 +361,13 @@ None observable by the CLI. | pid reused within one boot by a Claude process whose `startup` was never recorded (e.g. started before `on`) | can take the dead process's last session as predecessor, so `/clear` carries its task | announced, replaced by first declaration | | One transcript line longer than the longest string the runtime holds (512 MB) | every ingest fails when it reaches that line (inferred, not run). A transcript is otherwise read a block at a time: an ingest holds 1 MiB, up to about three copies of the longest line, and what the reading keeps (every record, every unrecognised line naming a directory), whatever the transcript's size | - | | Transcript rewritten in place, to the same size or larger, while an ingest reads it | not seen: a line may be spliced from the two versions and the offset saved inside the new one. Shrunk: that read counts nothing and the transcript is read again from where it stood. Replaced at its path: the read finishes the file it opened, and the next one reads the new file whole. Claude Code only appends; Codex is assumed to only append to a `.jsonl` | - | -| Task and ticket of a Codex call | come from its branch's declaration only: the plugin's hooks run in Claude Code, so nothing asks a Codex session for its task, and a session declaration never binds one | declare the branch | +| Codex hooks not trusted | Codex runs a plugin's hooks only once a person trusted each one, with `/hooks` in the TUI. The desktop app offers no review of its own: it skips an untrusted hook silently, and runs it once trusted from the TUI, since trust is kept in `config.toml` (observed); until then nothing asks, and ingest runs only from Claude Code or a report. Trust covers a hook's entry in `hooks.json` (command, timeout, `async`), not its script: an update that changes an entry needs trusting again, a change to the script alone does not | `/hooks` in the Codex TUI | +| Codex surfaces never asked | `codex exec`, the TUI in full-access mode (approval `never`), and the IDE extension and the desktop app on threads of their own (their `originator` is not `codex-tui`): their calls take a branch's declaration, or one made elsewhere for the session | declare the branch | +| A TUI thread resumed in the desktop app or the IDE extension | held there unless approvals are off, inferred from the source: a resume writes no new `session_meta`, so line 1 still reads `codex-tui`. The desktop app shows a block's reason (observed on its own thread); the IDE extension was not observed | - | +| A TUI thread resumed by `codex exec` with an auto-review approver (`--approve-for-me`, `approvals_reviewer = "auto_review"`) | not `bypassPermissions`, so its prompt would be held with nobody to see why. Not seen | - | +| A Codex `/clear` or fork | a new session with no carried task: asked again on an undeclared branch | declare the branch | | Branch of a Codex call | the branch its session started on: a branch changed during the session is not seen | - | -| A person who only uses Codex | measured when `aidd telemetry ingest` or `report` runs, which the plugin does at a Claude Code session start | run a report | +| A person who only uses Codex | measured when `aidd telemetry ingest` or `report` runs, which the plugin does at a Claude Code or a trusted Codex session start | run a report | | Codex cache writes | part of `input_tokens`, which `input` subtracts them from, as OpenAI's own formula does; billed only on GPT-5.6 and later. OpenAI has one write rate, so `cache_write_1h` is `null`. A cached read and write above `input_tokens` was never seen: its `input` would be below zero, which the ledger does not read as a record | - | | Codex versions | per-call records ship from Codex CLI 0.153. An earlier version writes calls only as running totals (`token_count`), which are not read: a session it wrote counts nothing. Observed: 0.149 pre-releases to 0.163 | - | | Codex calls written with no record | not counted. Off by default: background remote compaction, memory extraction, the Guardian scorer. Always: title generation, `codex exec --ephemeral` (no session file), realtime and voice conversations, failed, aborted or dropped-stream responses, and, before 0.163, a response cut short by its output limit | - | diff --git a/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/contract.md b/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/contract.md new file mode 100644 index 000000000..96a0ddb45 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/contract.md @@ -0,0 +1,65 @@ +--- +status: done +--- + +# Codex asks for its task + +## Need + +Codex calls are measured, but nothing asks a Codex session for its task: a call on a branch nobody declared stays unattributed, and Codex is ingested only when a Claude Code session starts or a report runs. The plugin's hooks already ship to Codex (the CLI translates them, and Codex sets `CLAUDE_PLUGIN_ROOT` for plugin hooks), and each one returns early because its guard accepts Claude Code's payload only. The observations behind every rule below are in `evidence.md`. + +## Decision + +The same three hooks serve both tools. Only what tells the tool, its session and whether a person can see a block differs. + +| Hook | Claude Code (unchanged) | Codex | +| --- | --- | --- | +| guard | payload `session_id` equals `CLAUDE_CODE_SESSION_ID`, transcript under a Claude `projects` directory | transcript is a Codex session file (`…/sessions/YYYY/MM/DD/rollout-….jsonl`), `session_id` set; Claude Code's guard still refuses it | +| `catch-up.cjs` (SessionStart, async) | ingest | ingest, the same command | +| `session-start.cjs` (SessionStart, sync) | records the process and carries a task across `/clear` and a branched session | does nothing: Codex gives a hook no process id. A Codex `/clear` or fork is asked again | +| `prompt-gate.cjs` (UserPromptSubmit) | asks, answers a typed declaration | the same, when a person can see the block | +| session of a declaration | `CLAUDE_CODE_SESSION_ID` | the payload's `session_id`, passed by the hook to `aidd telemetry task` | + +### Whether a person can see a block + +Codex shows a hook's block reason in the TUI only: `codex exec` prints none, its `--json` output nothing, the desktop app shows it (observed), and the IDE extension is undocumented. A hook receives nothing that names the surface. The session file's first line does: `session_meta.originator`. + +A Codex prompt is held only when all of these hold. Anything else, a field missing, another value, an unreadable file, a first line longer than the bound read, is "nobody to answer": the prompt goes through. A change in Codex can only stop the asking, never block work. + +| Condition | Why | +| --- | --- | +| the session file's first line is a `session_meta` with `originator` `codex-tui` and no object `source` | the TUI, the one surface seen to show a block. Its `source` is `cli` bare and `vscode` once plugins run it through the app server, so `originator` alone tells the surface. A sub-agent's own file has an object `source`, refused as a second guard | +| the payload has no `agent_id` | a sub-agent's spawn message reaches `UserPromptSubmit` with the root's `session_id`; nobody types it | +| `permission_mode` is exactly `default` (missing or any other value is nobody to answer) | a resumed thread keeps its first line, so a TUI thread resumed by `codex exec` still reads `codex-tui`; `exec` forces approval `never`, which is `bypassPermissions` | + +- The first line is read up to a bound, never the file whole. Codex writes and flushes it before it hands a hook the path. + +### The session a declaration binds + +- `aidd telemetry task` binds the session named by `AIDD_TELEMETRY_SESSION_ID`, else `CLAUDE_CODE_SESSION_ID`, read by the composition root. The Codex hook sets the first for the command it runs; nothing else does. +- A Codex block cannot hide the typed declaration: the reason says it was recorded, and the prompt is not sent to the model. + +## Properties + +| # | Property | Tested by | +| --- | --- | --- | +| H1 | A Codex payload passes the Codex guard and fails the Claude Code one; a Claude Code payload the reverse; a Codex session started inside Claude Code (which inherits `CLAUDE_CODE_SESSION_ID`) is Codex | shared fixture, both sides | +| H2 | A Codex prompt on an undeclared working branch is held when, and only when, every condition above holds | hook tests over every originator seen (`codex-tui`, `codex_exec`, `codex_vscode`, `Codex Desktop`, `Claude Code`), an object `source`, a payload with `agent_id`, each `permission_mode`, a missing file, a first line past the bound | +| H5 | The catch-up ingest stays asynchronous on Codex, in both install routes (plugin tree and the merged `.codex/hooks.json`) | translation tests: `async` and `timeout` kept | +| H3 | A declaration typed in a Codex session binds that session, and the report attributes its calls, sub-agents' included | end to end, synthetic Codex session file | +| H4 | Every Claude Code behaviour is unchanged | the existing hook suite and `check-claude-tokens-end-to-end.cjs`, both modes | + +## Limits + +Written into the usage contract and the plugin README; none is code. + +- Codex runs a plugin's hooks only once a person trusted each one (`/hooks` in the TUI). Until then nothing asks, and ingest runs only from Claude Code or a report. A plugin update that changes a hook's entry in `hooks.json` needs trusting again; a change to the script alone does not. +- Asked in the Codex TUI only, and not in full-access mode (approval `never`). `codex exec`, the IDE extension and the desktop app's own threads are never asked. +- A TUI thread resumed by `codex exec` with an auto-review approver (`--approve-for-me`, or `approvals_reviewer = "auto_review"`) is not `bypassPermissions`: its prompt would be held with no one to see why. Not observed. +- A Codex `/clear` or fork is a new session with no carried task: asked again on an undeclared branch. +- A TUI thread resumed in the desktop app or the IDE extension is held there unless approvals are off (inferred: a resume keeps the thread's first line). The desktop app shows a block's reason, observed on its own thread; the IDE extension was not observed. + +## Out of scope + +- Reading Codex's hook trust to report it in `aidd telemetry status`. +- The IDE extension, until observed. diff --git a/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/evidence.md b/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/evidence.md new file mode 100644 index 000000000..82916ea2b --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/evidence.md @@ -0,0 +1,123 @@ +# Evidence + +## Observed on a real Codex CLI 0.163.0 + +Isolated `CODEX_HOME`, scrubbed environment, a probe hook on SessionStart and UserPromptSubmit recording field and variable names only, never values of secrets or prompt text. + +| Fact | Observed | +| --- | --- | +| Hooks run in `codex exec` and in the TUI | SessionStart (`source: startup`) and UserPromptSubmit fire in both | +| Payload | `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `model`, `permission_mode`; SessionStart adds `source`, UserPromptSubmit adds `turn_id`, `prompt` | +| Session variable | none. Codex-specific variables: `CODEX_HOME`, `CODEX_MANAGED_BY_NPM`, `CODEX_MANAGED_PACKAGE_ROOT`. Hook stdin and stdout are not terminals | +| `permission_mode` | TUI `default`, `exec` `bypassPermissions` | +| `decision: block` with a reason, TUI | shown as "• Blocked by hook └ "; the prompt is not sent; that turn writes no call | +| `decision: block` with a reason, `exec` | the prompt is not sent, 0 tokens, exit 0; the reason appears on no channel (stdout JSON, stderr) | +| Session file first line | `session_meta`: TUI `source: cli`, `originator: codex-tui`; `exec` `source: exec`, `originator: codex_exec` | +| Tokens | `exec --json` `turn.completed` usage equals the sum of the session file's per-call records, for a one-call and a two-call turn | + +## From the openai/codex source at rust-v0.163.0 + +Report kept outside the repository; the points used here: + +- The IDE extension and the desktop app both record `source: vscode`; `originator` tells them apart (`codex_vscode`, `Codex Desktop`). No payload field or variable names the surface. +- A block works only for a synchronous hook. Async handlers exist (`async` is part of a handler's identity). +- Trust: a handler's identity is its event, matcher and handler config (command, timeout, async, …), not the script's content. A changed entry is skipped until trusted again; reordered entries are untrusted. +- A resumed thread keeps the originator it was created with; the originator is per process. +- Plugin hooks receive `PLUGIN_ROOT`, `PLUGIN_DATA`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_PLUGIN_DATA`. + +## The TUI's first line under plugins + +Isolated `CODEX_HOME`, the plugin installed by `aidd setup` from this branch, hooks trusted. The hooks ran, but the prompt was not asked: the session file's first line says `source: "vscode"`, `originator: "codex-tui"`, where the bare-config probe above said `source: "cli"`. With plugins installed the TUI runs through the app server, which hard-codes `SessionSource::VSCode`. `permission_mode` was `default` and `agent_id` absent. A probe hook saw no controlling terminal (`/dev/tty` gave ENXIO, the parent's tty `??`), so a terminal is no signal either. + +Read-only on the maintainer's sessions under `~/.codex/sessions`, first-line `source|originator` pairs, counts only: `vscode|codex-tui` 29, `cli|codex-tui` 4, `object|codex-tui` 266 (sub-agents), `exec|codex_exec` 143, `object|codex_exec` 26, `vscode|Codex Desktop` 3, `vscode|codex_work_desktop` 3, `vscode|Claude Code` 2. The rule therefore reads `originator` alone and refuses an object `source`. + +## First line of a Codex session file + +Measured read-only on the maintainer's machine, by byte length only, over every plain `rollout-*.jsonl` under `~/.codex/sessions`: 476 files, first line 18,552 bytes minimum, 22,740 median, 23,215 p90, 23,477 p99, 48,786 maximum; every file had a first newline. The 1,056 `.jsonl.zst` files were not measured: a hook is handed a plain file (a resumed compressed file is expanded back for append). The hook reads 1 MiB, about 21 times the largest seen. + +## Real run + +Codex 0.163.0. The plugin was installed by `aidd setup --source local` from this branch into an isolated `CODEX_HOME`, and its hooks trusted with `/hooks` in the TUI. Aggregates only. + +| Step | Observed | +| --- | --- | +| TUI, undeclared working branch, prompt | held: "• Blocked by hook └ Telemetry: no task on feat/real-run yet. …" | +| TUI, typed declaration | "• Blocked by hook └ Task real-run-demo (ticket T-42) declared. Send your prompt again." | +| TUI, prompt again | answered | +| `codex exec` on another undeclared branch | answered, never held; payload `permission_mode` `bypassPermissions`, first line `exec`/`codex_exec` | +| `report --axis task` | TUI calls on `real-run-demo`, the exec call "unattributed: no task declared". `--axis ticket` shows `T-42` | +| Tokens | report total 46,578 equals the sum of `usage.total_tokens` over the session files' records (3 calls). The exec session's 15,148 equals `codex exec --json` `turn.completed` input+output | +| First line under the TUI | `source` `vscode`, `originator` `codex-tui` | +| Hook process, TUI and exec | no controlling terminal (`/dev/tty` ENXIO) | +| Desktop app | a hook's `decision: block` is shown ("Le hook a bloqué ce message" followed by the reason); the prompt is not answered. Payload: first line `vscode`/`codex_work_desktop`, `permission_mode` `bypassPermissions` | +| Desktop, hook trust | Desktop trusted the folder but offered no hook review. The hook stayed untrusted and was skipped silently until it was trusted with `/hooks` in the TUI; Desktop then ran it, since trust is shared through `config.toml` | + +A branch declaration also attributes the branch's calls made before it. That rule predates this change and is unchanged. + +## Red run + +Each test written before its code, run and seen failing for the reason it names. + +- Phase 1, hooks (`node --test scripts/__tests__/aidd-telemetry-hooks.test.js`, `lib/tool.cjs` stubbed at the old behaviour, Claude Code only): `ℹ tests 74`, `ℹ fail 7`, `ℹ pass 67`. Failing, each for its own reason: + - `hook tool: every fixture case is Claude Code's, Codex's or neither`: `codex-rollout-path-with-the-inherited-claude-id`, `actual: null, expected: 'codex'`; + - `codex presence: a prompt is held when, and only when, every condition holds`: the rows expected held were not, `[ 'the TUI', 'the TUI started inside Claude Code', 'a first line well inside the bound' ]`; + - `codex: a typed declaration binds the payload's session`: `actual: null`, the block expected; + - `codex: a mistyped declaration is told so, naming Codex`: `actual: null`; + - `codex: session start does nothing, not even the consent check`: `actual: 1, expected: 2`, the gate never reached the consent check; + - `codex: the catch-up ingests, silently, once`: `actual: []`, the ingest expected; + - `codex: the gate asks on an undeclared working branch`: no block. + - Every Claude Code test stayed green (H4). +- Phase 2, the session a declaration binds (`vitest run tests/runtime/wiring/telemetry-wiring.unit.test.ts`): the five `the session a declaration binds` cases `×`, `declaringSessionOf` not exported. +- Phase 2, end to end (`vitest run --project e2e tests/e2e/telemetry-journey.e2e.test.ts -t Codex`, `declaringSessionOf` put back to `CLAUDE_CODE_SESSION_ID` alone): `×`, `ENOENT: no such file or directory, open '…/telemetry-dir/bindings/sessions.jsonl'`: the Codex declaration bound no session. +- Phase 3, the merged `.codex/hooks.json` (`vitest run tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts`): `Tests 3 failed | 38 passed (41)`, among them `expected { type: 'command', …(2) } to match object { async: true, timeout: 120 }`. +- Checker round 1, the session named for both tools: `✖ a stale AIDD_TELEMETRY_SESSION_ID exported by the user does not win over the session of the prompt` (`actual: [ '…00bb' ]`, the stale value bound) and `✖ a typed declaration reaches no model …` (`actual: null, expected: '…00aa'`). +- Real-run correction, presence table: `✖ codex presence: a prompt is held …`, the new rows `'the TUI through the app server (source vscode)'` and `'no source'` were not held. + +## Verification + +### The hooks, mutation by hand + +Stryker mutates `cli/` only, so each rule of the hooks was broken by hand, the hook suite run, and the change reverted. Every mutation turned red the test named for it: + +| Mutation | Red | +| --- | --- | +| Codex guard returns nothing | the fixture test, every `codex:` test, the presence table | +| Codex guard ignores `session_id` | `hook tool: every fixture case …` | +| any `.jsonl` path is Codex | the fixture test, two Claude Code consent and catch-up tests | +| Codex path not normalised | the fixture test (`codex-windows-rollout-path`) | +| `originator`, `source`, `type`, `agent_id` unchecked, each alone | the presence table | +| `permission_mode` only refuses `bypassPermissions` | the presence table (`no permission_mode`, `a permission_mode never seen`) | +| bound 4 MiB / 64 KiB | the presence table (`past the bound` / `well inside the bound`) | +| whole file read as the first line | the presence table and the Codex gate tests | +| env injection removed (`const env = process.env`) | the stale-value test, the Claude Code declaration test, the Codex declaration test | +| object `source` no longer refused | the presence table (`a sub-agent's file, its source an object`) | +| session start runs for Codex | `codex: session start does nothing, not even the consent check` | +| Codex declaration names no session | `codex: a typed declaration binds the payload's session …` | +| every declaration names a session | `a typed declaration reaches no model …` (Claude Code's binds its own variable) | +| Codex block hides the prompt | `codex: a typed declaration binds the payload's session …` | +| Codex told to ask Claude | `codex: a mistyped declaration is told so, naming Codex` | +| Codex presence read as Claude Code's | the presence table and the Codex gate tests | + +Two earlier mutations survived and the rule behind each was deleted rather than tested: an anchored `rollout-….jsonl$` path (the Claude Code guard's own pattern is reused), and a separate refusal of a first line with no end (a line cut at the bound is no JSON). + +## Gates + +Run in a sandbox: `HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR`, `CODEX_HOME` fresh temp dirs, `GIT_CONFIG_NOSYSTEM=1`. + +| Gate | Decisive line | +| --- | --- | +| `pnpm exec lefthook run pre-commit` | every job `✔️` (cli-architecture, cli-biome, cli-type-honesty, cli-typecheck, doc-duplication, markdown-links, no-local-paths, recipe-validity, referenced-paths, scripts-tests, summarize-plugin-catalogs, sync-readme-counts), `summary: (done in 48.10 seconds)`, exit 0 | +| `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | `ℹ tests 476`, `ℹ pass 476`, `ℹ fail 0`, exit 0 | +| `cli`: `pnpm lint` | exit 0, `Found 1 warning.` (`uninstall-use-case.ts`, untouched) | +| `cli`: `pnpm typecheck` | `tsc --noEmit`, exit 0 | +| `cli`: `pnpm test:arch` | `Tests 168 passed (168)` | +| `node scripts/check-cli-type-honesty.mjs` | `No type is widened through unknown, any or never …` | +| `cli`: `pnpm knip` | exit 0 | +| `cli`: `pnpm test` | `Test Files 526 passed \| 1 skipped (527)`, `Tests 6862 passed \| 1 skipped (6863)` | +| `cli`: `pnpm build` | `Bundle size: 705.9 KB / budget: 709 KB`, `OK: within budget` | +| `cli`: `pnpm smoke` | `PASS: 132 FAIL: 0 SKIP: 1 · coverage 100%` | +| `node scripts/check-claude-tokens-end-to-end.cjs` | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository`, exit 0 | +| `… --attended` | `…; asked, answered by the hook, kept across /clear`, exit 0 | +| `cli`: `node scripts/run-mutation.mjs --changed` | 15 mutants over 3 files, `score 100.0, 6 changed range(s) since origin/next` | +| `node scripts/check-no-local-paths.js` | `✅ No local paths: 0 leak(s) in 0 file(s)` | + diff --git a/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/plan.md b/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/plan.md new file mode 100644 index 000000000..5fd39f793 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_19_codex-asks-for-its-task/plan.md @@ -0,0 +1,37 @@ +--- +status: done +--- + +# Plan: Codex asks for its task + +Contract: `contract.md`. Each phase starts with its tests, run red first, the red run recorded in `evidence.md`. + +## Phase 1: the guard and the gate + +- The hooks tell the tool from the payload: Claude Code's guard unchanged, a Codex guard beside it, the shared fixture extended on both sides (H1). +- The Codex presence rule, reading the session file's first line up to a bound (H2). +- `session-start.cjs` does nothing for Codex; `catch-up.cjs` ingests for both. +- Acceptance: every H1 and H2 case red before, green after; the existing hook suite unchanged. + +## Phase 2: the session a declaration binds + +- The composition root reads `AIDD_TELEMETRY_SESSION_ID`, else `CLAUDE_CODE_SESSION_ID`; the Codex hook sets the first for the `aidd telemetry task` it runs. +- End to end over a synthetic Codex session file: declared in the session, report attributes its calls, sub-agents' included (H3). +- Acceptance: `check-claude-tokens-end-to-end.cjs` unchanged in both modes (H4). + +## Phase 3: the translation and the docs + +- The merged `.codex/hooks.json` route keeps `async` (and `timeout`) as the plugin route does (H5). +- Usage contract, plugin README, `00-init` and `01-usage` texts, `stop.md`'s "asked about" line: what Codex asks, where, trust, the limits. +- Acceptance: the repository gates green, then the real run below. + +## Real run (final acceptance, done: see `evidence.md`, "Real run") + +In an isolated `CODEX_HOME` holding a temporary copy of the maintainer's Codex credentials, deleted after: + +1. [x] Install the built plugin with `aidd`, trust its hooks with `/hooks`. +2. [x] In a clone that opted in, on an undeclared working branch: the TUI holds a prompt and says why. +3. [x] Type the declaration: it binds, the next prompt goes through. +4. [x] `codex exec` in the same clone: never held. +5. [x] `aidd telemetry report`: the session's calls are on the declared task. +6. [x] Resume a TUI thread in the desktop app and observe whether the block reason shows. If it does not, the presence rule needs a stronger signal before the PR ships; the orchestrator decides after the run. diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index 8c7f05dd0..7d6ed9b05 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -12,7 +12,7 @@ | its name | `USAGE_TOOLS` in `domain/usage-record.ts` | | its home relocated in every sandbox that runs the CLI | `tests/e2e/helpers.ts`, `tests/helpers/telemetry-sandbox.ts`, `scripts/check-claude-tokens-end-to-end.cjs`, `scripts/probe-identifier-join.cjs` | -Storage, attribution and the report need nothing. Task and ticket come only from `aidd telemetry task`, whoever runs it; the plugin's hooks only invoke it from Claude Code, so another tool's calls are attributed by branch. Rules: [usage contract](../../../aidd_docs/product/usage-contract.md#attribution). +Storage, attribution and the report need nothing. Task and ticket come only from `aidd telemetry task`, whoever runs it; the plugin's hooks invoke it from Claude Code and the Codex TUI, naming the session with `AIDD_TELEMETRY_SESSION_ID` where the tool sets no variable of its own. Another tool's calls are attributed by branch. Rules: [usage contract](../../../aidd_docs/product/usage-contract.md#attribution). ## Data flow @@ -72,7 +72,7 @@ flowchart LR ## The plugin side -`plugins/aidd-telemetry/hooks/` is plain Node and imports nothing from here. It asks for a task, answers a typed declaration by running `aidd telemetry task`, and records which session a process is on. Its reading of the stores is pinned against `scripts/__tests__/fixtures/telemetry-bindings/`, read by `tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts` and by the hook tests. +`plugins/aidd-telemetry/hooks/` is plain Node and imports nothing from here. It tells Claude Code's payload from Codex's (`hooks/lib/tool.cjs`), asks for a task, answers a typed declaration by running `aidd telemetry task`, and records which session a Claude Code process is on. Its reading of the stores is pinned against `scripts/__tests__/fixtures/telemetry-bindings/`, read by `tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts` and by the hook tests. ## Tests diff --git a/cli/src/contexts/telemetry/application/declare-task-use-case.ts b/cli/src/contexts/telemetry/application/declare-task-use-case.ts index 7b6840bcd..b5d7dc781 100644 --- a/cli/src/contexts/telemetry/application/declare-task-use-case.ts +++ b/cli/src/contexts/telemetry/application/declare-task-use-case.ts @@ -29,7 +29,8 @@ export type DeclareResult = export interface DeclareOptions { /** `AIDD_TELEMETRY=0`, read by the composition root. */ readonly refusedByEnvironment: boolean; - /** `CLAUDE_CODE_SESSION_ID`, read by the composition root; `null` outside a session. */ + /** `AIDD_TELEMETRY_SESSION_ID`, else `CLAUDE_CODE_SESSION_ID`, read by the composition root; + * `null` outside a session. */ readonly sessionId: string | null; readonly now: () => Date; } diff --git a/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts b/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts index ff237e885..312713712 100644 --- a/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts +++ b/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts @@ -16,7 +16,7 @@ type CursorFlatShape = { version: 1; hooks: Record }; type CodexHookEntry = { matcher?: string; - hooks: Array<{ type: string; command: string; timeout?: number; statusMessage?: string }>; + hooks: Array; }; type CodexHooksShape = { hooks?: Record }; @@ -196,26 +196,15 @@ function parseCodexHooks( return { hooks: parsed.hooks ?? {} }; } +/** Keeps each entry whole, as the plugin tree does: Codex trusts a hook by its whole entry, and an + * entry stripped of `async` runs in the way of the session it was written to stay out of. */ function convertToCodexEntries(matchers: ClaudeMatcherGroup[]): CodexHookEntry[] { return matchers.map((group) => ({ ...(group.matcher !== undefined ? { matcher: group.matcher } : {}), hooks: group.hooks - .filter((item) => typeof item.command === "string") - .map((item) => buildCodexHookItem(item)), + .filter( + (item): item is ClaudeHookItem & { command: string } => typeof item.command === "string" + ) + .map((item) => ({ ...item, type: item.type ?? "command" })), })); } - -function buildCodexHookItem(item: ClaudeHookItem): { - type: string; - command: string; - timeout?: number; - statusMessage?: string; -} { - const entry: { type: string; command: string; timeout?: number; statusMessage?: string } = { - type: item.type ?? "command", - command: item.command as string, - }; - if (typeof item.timeout === "number") entry.timeout = item.timeout; - if (typeof item.statusMessage === "string") entry.statusMessage = item.statusMessage; - return entry; -} diff --git a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts index a6d4abaa6..bbc35007b 100644 --- a/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts +++ b/cli/src/presentation/display/telemetry/telemetry-lifecycle-display.ts @@ -98,7 +98,7 @@ export function printOnResult(output: CLIOutput, result: OnResult): void { output.info(identityLine(result.identity)); output.info( "Next: declare what you work on with `aidd telemetry task [--ticket ]`, or `--none` for no task. " + - "In Claude Code, the aidd-telemetry plugin's hooks ask for it before the first prompt on an undeclared working branch." + "In Claude Code and the Codex TUI, the aidd-telemetry plugin's hooks ask for it before the first prompt on an undeclared working branch." ); } diff --git a/cli/src/runtime/wiring/telemetry.ts b/cli/src/runtime/wiring/telemetry.ts index 8910bc2a1..ec01e63ea 100644 --- a/cli/src/runtime/wiring/telemetry.ts +++ b/cli/src/runtime/wiring/telemetry.ts @@ -83,6 +83,12 @@ export function telemetryDir(): string { return telemetryDirOf(process.env, homedir()); } +/** The session a declaration binds: the one a hook names (Codex sets no session variable), else + * Claude Code's. An empty variable is no session. */ +export function declaringSessionOf(env: NodeJS.ProcessEnv): string | null { + return env.AIDD_TELEMETRY_SESSION_ID || env.CLAUDE_CODE_SESSION_ID || null; +} + /** macOS and Windows file systems answer one directory to several spellings. */ export function caseInsensitiveFileSystem(platform: NodeJS.Platform): boolean { return platform === "darwin" || platform === "win32"; @@ -114,8 +120,7 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { now: () => new Date(), }; const sessions = new SessionBindingStoreAdapter(bindingsDir, storage); - // An empty variable is no session: only a set, non-empty id is one a declaration can bind. - const sessionId = process.env.CLAUDE_CODE_SESSION_ID || null; + const sessionId = declaringSessionOf(process.env); const refusedByEnvironment = refusedByEnvironmentValue(process.env.AIDD_TELEMETRY); const repositories = new ConsentedRepositories(locator, consents, consentHistory); const branchDeclarations = new BranchDeclarations( diff --git a/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts b/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts index b0dbc4dce..6034a6004 100644 --- a/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts +++ b/cli/tests/contexts/telemetry/telemetry-bindings-fixture.unit.test.ts @@ -75,7 +75,7 @@ interface DirCase { home: string; env: NodeJS.ProcessEnv; } -interface ClaudeOnlyCase { +interface HookToolCase { payload_session_id: string | null; env_session_id: string | null; transcript_path: string | null; @@ -90,7 +90,7 @@ const cases = JSON.parse(text("cases.json")) as { cloneIdentity: Record; environmentRefusal: Record; telemetryDir: Record; - claudeOnly: Record; + hookTool: Record; }; const expected = JSON.parse(text("expected.json")) as Record>; @@ -236,16 +236,17 @@ describe("the shared fixture of task bindings", () => { expect(telemetryDirOf(input.env, input.home, joinPath)).toBe(expected.telemetryDir?.[name]); }); - it("states the Claude-only cases well formed, for the hook test to execute", () => { - for (const [name, input] of Object.entries(cases.claudeOnly)) { - expect(typeof expected.claudeOnly?.[name], name).toBe("boolean"); + it("states which tool each hook payload is, well formed, for the hook test to execute", () => { + for (const [name, input] of Object.entries(cases.hookTool)) { + expect(["claude", "codex", null], name).toContain(expected.hookTool?.[name]); expect(Object.keys(input).sort(), name).toEqual([ "env_session_id", "payload_session_id", "transcript_path", ]); } - expect(Object.values(expected.claudeOnly ?? {})).toContain(true); - expect(Object.values(expected.claudeOnly ?? {})).toContain(false); + expect(Object.values(expected.hookTool ?? {})).toEqual( + expect.arrayContaining(["claude", "codex", null]) + ); }); }); diff --git a/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts index cde2e7058..b8fad4b2b 100644 --- a/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts @@ -498,7 +498,7 @@ describe("mergeCodexFrameworkHooksJson, entry by entry", () => { }); }); - it("carries timeout and statusMessage only when each is declared in its own type", () => { + it("keeps every field of an entry, as the plugin route does, and defaults only its type", () => { const { content } = mergeCodexFrameworkHooksJson( null, JSON.stringify({ @@ -506,8 +506,8 @@ describe("mergeCodexFrameworkHooksJson, entry by entry", () => { Stop: [ { hooks: [ - { command: "a", timeout: 5, statusMessage: "working" }, - { command: "b", timeout: "5", statusMessage: 7 }, + { command: "a", timeout: 5, statusMessage: "working", async: true }, + { type: "prompt", command: "b", timeout: "5" }, ], }, ], @@ -520,8 +520,8 @@ describe("mergeCodexFrameworkHooksJson, entry by entry", () => { SessionEnd: [ { hooks: [ - { type: "command", command: "a", timeout: 5, statusMessage: "working" }, - { type: "command", command: "b" }, + { type: "command", command: "a", timeout: 5, statusMessage: "working", async: true }, + { type: "prompt", command: "b", timeout: "5" }, ], }, ], @@ -530,6 +530,28 @@ describe("mergeCodexFrameworkHooksJson, entry by entry", () => { }); }); +describe("the telemetry plugin's hooks on both Codex routes", () => { + const shipped = () => + readFileSync(join(REPOSITORY_ROOT, "plugins", "aidd-telemetry", "hooks", "hooks.json"), "utf8"); + const entries = (document: string) => + ( + JSON.parse(document) as { hooks: Record[] }[]> } + ).hooks.SessionStart?.flatMap((group) => group.hooks); + + it("keeps the catch-up async with its timeout in the merged .codex/hooks.json", () => { + const merged = entries(mergeCodexFrameworkHooksJson(null, shipped()).content); + expect(merged?.find((entry) => String(entry.command).endsWith("catch-up.cjs"))).toMatchObject({ + async: true, + timeout: 120, + }); + }); + + it("writes the same entries as the plugin tree, so one trust review covers both", () => { + const merged = JSON.parse(mergeCodexFrameworkHooksJson(null, shipped()).content); + expect(merged).toStrictEqual(JSON.parse(renameCodexHookEvents(shipped()))); + }); +}); + describe("a matcher group that declares no hooks list", () => { const groupless = JSON.stringify({ hooks: { Stop: [{ matcher: "x" }, { hooks: [{ command: "a" }] }] }, diff --git a/cli/tests/e2e/helpers.ts b/cli/tests/e2e/helpers.ts index b44dc993c..a0dfd3231 100644 --- a/cli/tests/e2e/helpers.ts +++ b/cli/tests/e2e/helpers.ts @@ -248,6 +248,7 @@ export function sandboxedEnv( // the test's explicit `env` is applied. delete base.CODEX_THREAD_ID; delete base.CLAUDE_CODE_SESSION_ID; + delete base.AIDD_TELEMETRY_SESSION_ID; if (options?.realHome) { return { ...base, ...extra, AIDD_USER_CONFIG_DIR: join(fakeHome, ".config", "aidd") }; } diff --git a/cli/tests/e2e/telemetry-journey.e2e.test.ts b/cli/tests/e2e/telemetry-journey.e2e.test.ts index e7cbd924f..48238e775 100644 --- a/cli/tests/e2e/telemetry-journey.e2e.test.ts +++ b/cli/tests/e2e/telemetry-journey.e2e.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { appendFile, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { delimiter, dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { git, initRepository, sandboxGitEnv } from "../helpers/git-sandbox.js"; @@ -32,6 +32,9 @@ const S_MAIN = "a1b2c3d4-0000-4000-8000-0000000000a1"; const S_CLEARED = "b2c3d4e5-0000-4000-8000-0000000000b2"; const S_HEADLESS = "c3d4e5f6-0000-4000-8000-0000000000c3"; const S_OTHER = "d4e5f6a7-0000-4000-8000-0000000000d4"; +const S_CODEX = "00000000-0000-4000-8000-0000000000e5"; +const S_CODEX_CHILD = "00000000-0000-4000-8000-0000000000f6"; +const S_CODEX_EXEC = "00000000-0000-4000-8000-0000000000a7"; const CLAUDE_PID = "424242"; interface Call { @@ -461,6 +464,103 @@ describe("the telemetry journey, on the built binary", () => { }); }); + it("asks a Codex TUI session, binds its declaration to that session, and attributes its calls and its sub-agent's", async () => { + await optIn(); + const sessions = join(env.fakeHome, ".codex", "sessions", "2026", "10", "19"); + await mkdir(sessions, { recursive: true }); + const line = ( + type: string, + payload: Record, + at = "2026-10-19T10:00:00.000Z" + ) => `${JSON.stringify({ timestamp: at, type, payload })}\n`; + // No `git` in either first line: the branch is declared too, and must not be what attributes. + const meta = (id: string, source: unknown, originator: string) => + line("session_meta", { id, cwd: repo, cli_version: "0.163.0", source, originator }); + const rootFile = join(sessions, `rollout-2026-10-19T10-00-00-${S_CODEX}.jsonl`); + const childFile = join(sessions, `rollout-2026-10-19T10-00-01-${S_CODEX_CHILD}.jsonl`); + const execFile = join(sessions, `rollout-2026-10-19T10-00-02-${S_CODEX_EXEC}.jsonl`); + const turn = line("turn_context", { cwd: repo, model: "gpt-x" }); + await writeFile(rootFile, meta(S_CODEX, "cli", "codex-tui") + turn); + await writeFile(execFile, meta(S_CODEX_EXEC, "exec", "codex_exec") + turn); + + // The hook as Codex runs it: none of Claude Code's variables, the session in the payload. + const path = [binDir, sandboxedEnv(env.fakeHome).PATH].join(delimiter); + const codex = (session: string, file: string, permission: string, text: string) => { + const run = spawnSync(process.execPath, [join(HOOKS, "prompt-gate.cjs")], { + cwd: repo, + input: JSON.stringify({ + session_id: session, + transcript_path: file, + cwd: repo, + hook_event_name: "UserPromptSubmit", + permission_mode: permission, + turn_id: "t", + prompt: text, + }), + encoding: "utf8", + env: sandboxedEnv(env.fakeHome, { ...sandboxVariables(), PATH: path, Path: path }), + }); + expect(run.status, run.stderr).toBe(0); + return run.stdout.trim() === "" ? null : (JSON.parse(run.stdout) as Record); + }; + + expect(codex(S_CODEX, rootFile, "default", "build the checkout")).toMatchObject({ + decision: "block", + reason: expect.stringContaining("Telemetry: no task on feat/x yet."), + }); + // `codex exec` shows nobody the reason: never held. + expect(codex(S_CODEX_EXEC, execFile, "bypassPermissions", "run it")).toBeNull(); + expect( + codex(S_CODEX, rootFile, "default", "aidd telemetry task codex-fix --ticket PROJ-7") + ).toEqual({ + decision: "block", + reason: "Task codex-fix (ticket PROJ-7) declared. Send your prompt again.", + }); + expect(await lines(join(telemetry, "bindings", "sessions.jsonl"))).toMatchObject([ + { session_id: S_CODEX, task: "codex-fix", ticket: "PROJ-7", by: "hook-intercept" }, + ]); + expect(codex(S_CODEX, rootFile, "default", "build the checkout")).toBeNull(); + + // Calls after the declaration, the sub-agent's in its own file under the root's session. + const base = Date.now() + 60_000; + const call = (id: string, thread: string, minute: number) => + line( + "token_usage_record", + { + response_id: `resp_${id}`, + session_id: S_CODEX, + thread_id: thread, + usage: { + input_tokens: 10, + cached_input_tokens: 4, + cache_write_input_tokens: 0, + output_tokens: 2, + reasoning_output_tokens: 1, + total_tokens: 12, + }, + }, + new Date(base + minute * 60_000).toISOString() + ); + await appendFile(rootFile, call("1", S_CODEX, 1) + call("2", S_CODEX, 2)); + const spawned = { subagent: { thread_spawn: { parent_thread_id: S_CODEX, depth: 1 } } }; + await writeFile( + childFile, + meta(S_CODEX_CHILD, spawned, "codex-tui") + turn + call("3", S_CODEX_CHILD, 3) + ); + + const day = new Date(base).toISOString().slice(0, 10); + const last = new Date(base + 3 * 60_000).toISOString().slice(0, 10); + const task = await report("task", day, last); + expect(task.rows.map((row) => [row.kind, row.key, row.records])).toEqual([ + ["value", "codex-fix", 3], + ]); + const agents = await report("agent", day, last); + expect(agents.rows.map((row) => [row.key, row.records]).sort()).toEqual([ + ["main", 2], + ["subagent", 1], + ]); + }); + it("shows nothing, blocks nothing and stores nothing in a project that never opted in", async () => { const main: Session = { id: S_MAIN }; expect(sessionStart(main, "startup").stdout).toBe(""); diff --git a/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts b/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts index 2384b95b3..86ace85ed 100644 --- a/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts +++ b/cli/tests/presentation/display/telemetry/telemetry-lifecycle-display.unit.test.ts @@ -40,6 +40,7 @@ describe("printOnResult", () => { expect(output.lines[3]).toMatch(/^Next: declare what you work on with /u); expect(output.lines[3]).toContain("aidd telemetry task [--ticket ]"); expect(output.lines[3]).toContain("the aidd-telemetry plugin's hooks ask"); + expect(output.lines[3]).toContain("Codex TUI"); }); it("says who the clone is measured as, before the next step", () => { diff --git a/cli/tests/runtime/wiring/telemetry-wiring.unit.test.ts b/cli/tests/runtime/wiring/telemetry-wiring.unit.test.ts index 672aa39e7..7489159d5 100644 --- a/cli/tests/runtime/wiring/telemetry-wiring.unit.test.ts +++ b/cli/tests/runtime/wiring/telemetry-wiring.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { caseInsensitiveFileSystem } from "../../../src/runtime/wiring/telemetry.js"; +import { + caseInsensitiveFileSystem, + declaringSessionOf, +} from "../../../src/runtime/wiring/telemetry.js"; describe("which file systems answer one directory to several spellings", () => { it.each([ @@ -11,3 +14,27 @@ describe("which file systems answer one directory to several spellings", () => { expect(caseInsensitiveFileSystem(platform)).toBe(expected); }); }); + +describe("the session a declaration binds", () => { + it.each([ + [ + "the one a hook names, over Claude Code's", + { AIDD_TELEMETRY_SESSION_ID: "a", CLAUDE_CODE_SESSION_ID: "b" }, + "a", + ], + ["Claude Code's, when no hook names one", { CLAUDE_CODE_SESSION_ID: "b" }, "b"], + [ + "Claude Code's, when the one named is empty", + { AIDD_TELEMETRY_SESSION_ID: "", CLAUDE_CODE_SESSION_ID: "b" }, + "b", + ], + ["none, outside a session", {}, null], + [ + "none, when both are empty", + { AIDD_TELEMETRY_SESSION_ID: "", CLAUDE_CODE_SESSION_ID: "" }, + null, + ], + ] as const)("is %s", (_name, env, expected) => { + expect(declaringSessionOf(env)).toBe(expected); + }); +}); diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index 17e3ae7bc..5845c7caf 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -21,14 +21,27 @@ flowchart LR - Only clones that ran `aidd telemetry on` are measured, from that moment on. - A task and a ticket come only from what you declare; nothing is guessed. - Every axis splits the same total. -- Codex is read with the rest, from `CODEX_HOME` or `~/.codex`, whenever `aidd telemetry ingest` or `report` runs. The plugin's hooks run in Claude Code, so nothing asks a Codex session for its task: a Codex call takes it from its branch's declaration only. +- Codex is read with the rest, from `CODEX_HOME` or `~/.codex`, whenever `aidd telemetry ingest` or `report` runs, a trusted Codex session start included. ## Start 1. Install: `npm install -g @ai-driven-dev/cli`, then `aidd plugin install aidd-telemetry`. Its hooks need `node` on `PATH`. 2. In each clone to measure: `aidd telemetry on`. With no identity set, it asks how you appear: your git name, another identifier, or anonymous. Without a terminal, answer with `--yes --as ` or `--yes --anonymous`. -3. Work. While the branch has no task, Claude Code holds each prompt and asks for one: send `aidd telemetry task [--ticket ]` (or `--none`) as your next prompt, or run it in a terminal. Once declared, the branch is never asked again, unless a copy or rename onto it changes its keys. A declaration belongs to the branch it was made on: a branch copied or renamed with `git branch -c` or `-m` is asked again, unless the declaration was written by cli-v5.5.0 or earlier (see the usage contract's limits). -4. Read: `aidd telemetry report --axis task`. +3. In Codex, trust the plugin's hooks once with `/hooks` in the TUI. Codex runs none it was not told to trust, and asks again when an update changes a hook's entry. The desktop app offers no review of its own: it runs the hooks once they are trusted from the TUI. +4. Work. While the branch has no task, Claude Code and the Codex TUI hold each prompt and ask for one: send `aidd telemetry task [--ticket ]` (or `--none`) as your next prompt, or run it in a terminal. Once declared, the branch is never asked again, unless a copy or rename onto it changes its keys. A declaration belongs to the branch it was made on: a branch copied or renamed with `git branch -c` or `-m` is asked again, unless the declaration was written by cli-v5.5.0 or earlier (see the usage contract's limits). +5. Read: `aidd telemetry report --axis task`. + +## Where you are asked + +| Surface | Asked | +| --- | --- | +| Claude Code, terminal and desktop app | yes | +| Claude Code, an IDE extension or the SDK | no | +| Codex TUI | yes, unless in full-access mode (approval `never`) | +| `codex exec`, and the Codex IDE extension and desktop app on their own threads (`originator` is not `codex-tui`) | no | +| a Codex TUI thread resumed in the desktop app or the IDE extension | yes, unless approvals are off there (inferred: a resume keeps the thread's first line). The desktop app shows a block's reason; the IDE extension was not observed | + +Where nobody is asked, a call takes its task from its branch's declaration, so declare the branch with `aidd telemetry task` in a terminal. A Codex `/clear` or fork starts a new session, which is asked again on an undeclared branch; Claude Code keeps the task across both. The usage contract lists the rest. ## Commands diff --git a/plugins/aidd-telemetry/hooks/lib/claude-only.cjs b/plugins/aidd-telemetry/hooks/lib/claude-only.cjs index 72b0cb57a..96d1adb81 100644 --- a/plugins/aidd-telemetry/hooks/lib/claude-only.cjs +++ b/plugins/aidd-telemetry/hooks/lib/claude-only.cjs @@ -1,7 +1,7 @@ // Codex delivers the same event names, and a Codex started inside Claude inherits Claude's // session variable. Only a payload whose id equals the variable AND whose transcript lives // in a Claude `projects` directory is Claude's own. -const CODEX_ROLLOUT = /\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-/u; +const { CODEX_ROLLOUT } = require("./codex-session-file.cjs"); const CLAUDE_TRANSCRIPT = /(?:^|\/)projects\/.+\.jsonl$/u; function text(value) { diff --git a/plugins/aidd-telemetry/hooks/lib/codex-session-file.cjs b/plugins/aidd-telemetry/hooks/lib/codex-session-file.cjs new file mode 100644 index 000000000..843ab262b --- /dev/null +++ b/plugins/aidd-telemetry/hooks/lib/codex-session-file.cjs @@ -0,0 +1,4 @@ +// Where Codex keeps a session's file; only Codex hands a hook such a path. +const CODEX_ROLLOUT = /\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-/u; + +module.exports = { CODEX_ROLLOUT }; diff --git a/plugins/aidd-telemetry/hooks/lib/context.cjs b/plugins/aidd-telemetry/hooks/lib/context.cjs index 276564cc8..95dd409ec 100644 --- a/plugins/aidd-telemetry/hooks/lib/context.cjs +++ b/plugins/aidd-telemetry/hooks/lib/context.cjs @@ -1,17 +1,20 @@ const fs = require("node:fs"); -const { isClaudePayload } = require("./claude-only.cjs"); const { consentGranted } = require("./consent.cjs"); +const { hookTool } = require("./tool.cjs"); -/** The guards every hook shares, cheapest first: Claude's own payload (environment only), then - * consent. Consent reads `consents.jsonl` and spawns no git while no interval is open on the - * machine; otherwise it spawns two git calls for the directory (`rev-parse --git-common-dir`, - * `config --local --get`), and `stat`s the clone's git dir. Null means the hook does nothing, - * silently. */ -function guardedContext(payload, env = process.env) { - if (!isClaudePayload(payload, env)) return null; +const EVERY_TOOL = ["claude", "codex"]; + +/** The guards every hook shares, cheapest first: a payload of one of the `tools` (environment + * only), then consent. Consent reads `consents.jsonl` and spawns no git while no interval is open + * on the machine; otherwise it spawns two git calls for the directory (`rev-parse + * --git-common-dir`, `config --local --get`), and `stat`s the clone's git dir. Null means the hook + * does nothing, silently. */ +function guardedContext(payload, env = process.env, tools = EVERY_TOOL) { + const tool = hookTool(payload, env); + if (tool === null || !tools.includes(tool)) return null; const cwd = typeof payload.cwd === "string" && isDirectory(payload.cwd) ? payload.cwd : process.cwd(); if (!consentGranted(cwd, env)) return null; - return { cwd }; + return { cwd, tool }; } function isDirectory(dir) { diff --git a/plugins/aidd-telemetry/hooks/lib/presence.cjs b/plugins/aidd-telemetry/hooks/lib/presence.cjs index ca5ff440e..38d04779a 100644 --- a/plugins/aidd-telemetry/hooks/lib/presence.cjs +++ b/plugins/aidd-telemetry/hooks/lib/presence.cjs @@ -1,3 +1,5 @@ +const fs = require("node:fs"); + // Both variables are undocumented, so the block needs both to agree that a person is at the // keyboard, on a surface seen to show the person a block's reason. Anything else, a missing // variable or a surface never seen included, is "not present": a change in Claude can only stop @@ -9,4 +11,40 @@ function personIsPresent(env = process.env) { return env.CLAUDE_CODE_SESSION_ATTENDED === "1" && SHOWS_A_BLOCK.has(env.CLAUDE_CODE_ENTRYPOINT); } -module.exports = { personIsPresent }; +// The first line of a Codex session file holds its base instructions: seen up to 48 KiB. +const FIRST_LINE_BOUND = 1024 * 1024; + +/** The first line of `file`, read up to the bound: a line cut there is no JSON. */ +function firstLine(file) { + let fd; + try { + fd = fs.openSync(file, "r"); + const buffer = Buffer.alloc(FIRST_LINE_BOUND); + const read = fs.readSync(fd, buffer, 0, FIRST_LINE_BOUND, 0); + return buffer.toString("utf8", 0, read).split("\n", 1)[0]; + } catch { + return null; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +/** Codex hands a hook nothing that names its surface, and shows a block's reason only in the TUI. + * So a person is there only when the session file's `originator` says the TUI made it (its + * `source` is `cli` bare and `vscode` under the app server, so it tells nothing; a sub-agent's + * file has an object `source`), no sub-agent sent the prompt, and approvals are on: `codex exec` forces them + * off, and a resumed thread keeps the first line of the surface that made it. Anything else is + * nobody to answer. */ +function codexPersonIsPresent(payload) { + if (payload.agent_id !== undefined || payload.permission_mode !== "default") return false; + let meta; + try { + meta = JSON.parse(firstLine(payload.transcript_path)); + } catch { + return false; + } + const { source, originator } = meta?.payload ?? {}; + return meta?.type === "session_meta" && originator === "codex-tui" && typeof source !== "object"; +} + +module.exports = { personIsPresent, codexPersonIsPresent }; diff --git a/plugins/aidd-telemetry/hooks/lib/tool.cjs b/plugins/aidd-telemetry/hooks/lib/tool.cjs new file mode 100644 index 000000000..94005676b --- /dev/null +++ b/plugins/aidd-telemetry/hooks/lib/tool.cjs @@ -0,0 +1,22 @@ +const { isClaudePayload } = require("./claude-only.cjs"); +const { CODEX_ROLLOUT } = require("./codex-session-file.cjs"); + +function text(value) { + return typeof value === "string" && value !== "" ? value : null; +} + +/** A Codex session file, which only Codex hands a hook. A Codex started inside Claude Code inherits + * Claude's session variable, so the path decides, never the variable. */ +function isCodexPayload(payload) { + if (payload === null || typeof payload !== "object" || text(payload.session_id) === null) return false; + const transcript = text(payload.transcript_path); + return transcript !== null && CODEX_ROLLOUT.test(transcript.replace(/\\/gu, "/")); +} + +/** Which tool's hook this payload is: `claude`, `codex`, or null, for which a hook does nothing. */ +function hookTool(payload, env = process.env) { + if (isClaudePayload(payload, env)) return "claude"; + return isCodexPayload(payload) ? "codex" : null; +} + +module.exports = { hookTool }; diff --git a/plugins/aidd-telemetry/hooks/prompt-gate.cjs b/plugins/aidd-telemetry/hooks/prompt-gate.cjs index 8fb94caec..9a91780f0 100644 --- a/plugins/aidd-telemetry/hooks/prompt-gate.cjs +++ b/plugins/aidd-telemetry/hooks/prompt-gate.cjs @@ -1,21 +1,23 @@ // UserPromptSubmit: answers a typed declaration without the model, and holds every prompt on // an undeclared working branch until one is made, so no token is spent before the work has a // task. A declared branch is never asked again, unless a copy or rename onto it changes its -// keys. Inert outside Claude, outside an opted-in project, and whenever nobody is there to -// answer. +// keys. Inert outside Claude Code and Codex, outside an opted-in project, and whenever nobody is +// there to answer. const { resolveAidd, runAidd } = require("./lib/aidd.cjs"); const { guardedContext } = require("./lib/context.cjs"); const { attemptsDeclaration, isIntercept, parseDeclaration } = require("./lib/declaration.cjs"); const { lookup } = require("./lib/lookup.cjs"); const { runHook } = require("./lib/hook-io.cjs"); -const { personIsPresent } = require("./lib/presence.cjs"); +const { codexPersonIsPresent, personIsPresent } = require("./lib/presence.cjs"); const { telemetryDir } = require("./lib/telemetry-dir.cjs"); const DECLARE_TIMEOUT_MS = 20_000; const PROBE_TIMEOUT_MS = 10_000; +const TOOL_NAME = { claude: "Claude", codex: "Codex" }; /** A declaration is answered and gone, so its prompt is not kept. A prompt blocked for want of - * a task stays visible: it is the person's real request, to retype once declared. */ + * a task stays visible: it is the person's real request, to retype once declared. Codex was + * never seen to accept a hidden prompt, so a Codex block keeps every prompt. */ function block(reason, { keepPrompt = false } = {}) { const output = { decision: "block", reason }; if (!keepPrompt) { @@ -24,18 +26,23 @@ function block(reason, { keepPrompt = false } = {}) { return output; } -function intercept(argv, cwd) { +/** The hook names the session the declaration binds: Codex sets no session variable, and for + * Claude Code the payload's id is `CLAUDE_CODE_SESSION_ID` by the guard, so a stale exported + * `AIDD_TELEMETRY_SESSION_ID` cannot win. */ +function intercept(argv, { cwd, tool }, sessionId) { + const keepPrompt = tool === "codex"; + const env = { ...process.env, AIDD_TELEMETRY_SESSION_ID: sessionId }; const aidd = resolveAidd(); if (aidd === null) { - return block("aidd is needed to record the declaration and was not found on PATH. Install @ai-driven-dev/cli, then declare again."); + return block("aidd is needed to record the declaration and was not found on PATH. Install @ai-driven-dev/cli, then declare again.", { keepPrompt }); } - const run = runAidd(aidd, argv, { cwd, timeout: DECLARE_TIMEOUT_MS }); + const run = runAidd(aidd, argv, { cwd, env, timeout: DECLARE_TIMEOUT_MS }); if (run.refused) { - return block("This declaration holds characters that cannot be passed safely here. Use only ASCII letters, digits and - . _ : / @ # + , =."); + return block("This declaration holds characters that cannot be passed safely here. Use only ASCII letters, digits and - . _ : / @ # + , =.", { keepPrompt }); } - if (run.status === 0) return block(`${declared(argv)} Send your prompt again.`); + if (run.status === 0) return block(`${declared(argv)} Send your prompt again.`, { keepPrompt }); const said = (run.stdout.trim() || run.stderr.trim()).slice(0, 2000); - return block(`Declaration failed${run.status === null ? " (aidd did not answer)" : ""}. ${said}`.trim()); + return block(`Declaration failed${run.status === null ? " (aidd did not answer)" : ""}. ${said}`.trim(), { keepPrompt }); } /** What a declaration that went through recorded, in the words the person typed. */ @@ -62,9 +69,9 @@ function ask(branch) { /** A declaration the person typed and the hook could not read: said so, with the grammar, and * never run or forwarded. The prompt stays visible to retype. */ -function notUnderstood() { +function notUnderstood(tool) { return block( - 'Not a declaration, nothing sent. Send "aidd telemetry task [--ticket ]" (quote a name with spaces), or "--none". To ask Claude instead, end with "?".', + `Not a declaration, nothing sent. Send "aidd telemetry task [--ticket ]" (quote a name with spaces), or "--none". To ask ${TOOL_NAME[tool]} instead, end with "?".`, { keepPrompt: true } ); } @@ -73,13 +80,14 @@ runHook((payload) => { // The guard first: it also ends the interval of a clone whose key was turned off by hand, and // that holds whether or not anyone is there to answer. const context = guardedContext(payload); - if (context === null || !personIsPresent()) return null; + if (context === null) return null; + if (!(context.tool === "codex" ? codexPersonIsPresent(payload) : personIsPresent())) return null; // A prompt that only starts like a declaration, or holds anything a shell would read, is // never run. One that reads like a mistyped declaration is told so; any other is an // ordinary prompt and meets the ordinary gate below. const declaration = isIntercept(payload.prompt) ? parseDeclaration(payload.prompt) : null; - if (declaration !== null) return intercept(declaration, context.cwd); - if (attemptsDeclaration(payload.prompt)) return notUnderstood(); + if (declaration !== null) return intercept(declaration, context, payload.session_id); + if (attemptsDeclaration(payload.prompt)) return notUnderstood(context.tool); const found = lookup({ cwd: context.cwd, diff --git a/plugins/aidd-telemetry/hooks/session-start.cjs b/plugins/aidd-telemetry/hooks/session-start.cjs index 784bf37b2..7415da4d4 100644 --- a/plugins/aidd-telemetry/hooks/session-start.cjs +++ b/plugins/aidd-telemetry/hooks/session-start.cjs @@ -1,5 +1,6 @@ // Sync SessionStart: records which session a Claude process is on, and when a /clear or a -// branch starts a new session in the same process, carries the task it had. +// branch starts a new session in the same process, carries the task it had. Codex gives a hook +// no process id, so for Codex it does nothing: a Codex /clear or fork is asked again. const os = require("node:os"); const path = require("node:path"); const { readCarries, readDeclarations, resolveSession } = require("./lib/binding.cjs"); @@ -42,7 +43,7 @@ function describe(binding, source) { } runHook((payload) => { - const context = guardedContext(payload); + const context = guardedContext(payload, process.env, ["claude"]); if (context === null) return null; const dir = telemetryDir(); const processes = path.join(dir, "bindings", "processes.jsonl"); diff --git a/plugins/aidd-telemetry/skills/00-init/actions/start.md b/plugins/aidd-telemetry/skills/00-init/actions/start.md index 680e71052..ab6c09130 100644 --- a/plugins/aidd-telemetry/skills/00-init/actions/start.md +++ b/plugins/aidd-telemetry/skills/00-init/actions/start.md @@ -14,7 +14,7 @@ Measurement is on for the project, or nothing changed and the reason is stated. 1. **Explain.** Tell the person, before asking anything: - what is measured: the token counts of Claude Code and Codex sessions run in this project, read from each tool's own session files (Codex's under `CODEX_HOME`, else `~/.codex`), split into input, output, cache read and cache write; - - what is asked: one question per working branch, a task name and optionally a ticket, only when a person is at the keyboard; + - what is asked: one question per working branch, a task name and optionally a ticket, only when a person is at the keyboard, in Claude Code or the Codex TUI. Codex asks only once the person has reviewed and trusted this plugin's checks in its TUI, which lists them for review, and never in `codex exec`, its IDE extension, its desktop app or full-access mode; - when it starts: now. Measurement starts at the moment it is turned on, and what this project's sessions did before is not counted, whatever Claude Code still holds of it; - where it stays: on this machine, and nothing is sent anywhere; - whose consent it is: this clone's alone, kept in its git config and never committed, so a teammate is not measured until they run it in theirs. diff --git a/plugins/aidd-telemetry/skills/00-init/actions/stop.md b/plugins/aidd-telemetry/skills/00-init/actions/stop.md index 4d601a9be..af46127ab 100644 --- a/plugins/aidd-telemetry/skills/00-init/actions/stop.md +++ b/plugins/aidd-telemetry/skills/00-init/actions/stop.md @@ -12,7 +12,7 @@ Measurement is off for the project, and the person knows the measured data is st ## Process -1. **Confirm.** Say what stopping does: new Claude Code or Codex work in this project is no longer counted, nor asked about, and what was measured stays. Wait for a yes. +1. **Confirm.** Say what stopping does: new Claude Code or Codex work in this project is no longer counted, nor asked about where it was asked (Claude Code, the Codex TUI), and what was measured stays. Wait for a yes. 2. **Turn off.** Run `aidd telemetry off` and relay its message. 3. **Offer the rest.** Say that deleting what was measured is a separate step, and that it needs its own confirmation. diff --git a/scripts/__tests__/aidd-telemetry-hooks.test.js b/scripts/__tests__/aidd-telemetry-hooks.test.js index 36723b17f..f1e1c578b 100644 --- a/scripts/__tests__/aidd-telemetry-hooks.test.js +++ b/scripts/__tests__/aidd-telemetry-hooks.test.js @@ -24,6 +24,8 @@ const { readCarries, readDeclarations } = require(path.join(HOOKS, "lib/binding. const { lookup } = require(path.join(HOOKS, "lib/lookup.cjs")); const { parseDeclaration } = require(path.join(HOOKS, "lib/declaration.cjs")); const { cmdLine } = require(path.join(HOOKS, "lib/aidd.cjs")); +const { isClaudePayload } = require(path.join(HOOKS, "lib/claude-only.cjs")); +const { hookTool } = require(path.join(HOOKS, "lib/tool.cjs")); const WINDOWS = process.platform === "win32"; const SESSION_ONE_FOR_GATE = { @@ -63,7 +65,8 @@ const fs = require("node:fs"); const path = require("node:path"); const argv = process.argv.slice(2); if (process.env.FAKE_AIDD_LOG) { - fs.appendFileSync(process.env.FAKE_AIDD_LOG, JSON.stringify({ argv, cwd: process.cwd() }) + "\\n"); + const session = process.env.AIDD_TELEMETRY_SESSION_ID ?? null; + fs.appendFileSync(process.env.FAKE_AIDD_LOG, JSON.stringify({ argv, cwd: process.cwd(), session }) + "\\n"); } if (process.env.FAKE_AIDD_NOISE) { process.stdout.write("noise\\n"); process.stderr.write("noise\\n"); } if (argv.includes("--help")) process.exit(process.env.FAKE_AIDD_NO_TASK ? 1 : 0); @@ -73,7 +76,7 @@ if (argv[0] === "telemetry" && argv[1] === "task") { const dir = path.join(process.env.AIDD_TELEMETRY_DIR, "bindings"); fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(path.join(dir, "sessions.jsonl"), JSON.stringify({ - session_id: process.env.CLAUDE_CODE_SESSION_ID, task: name, ticket: null, none: name === null, + session_id: process.env.AIDD_TELEMETRY_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID, task: name, ticket: null, none: name === null, declared_at: new Date().toISOString(), by: "hook-intercept", }) + "\\n"); process.stdout.write("Declared " + (name ?? "none") + "\\n"); @@ -381,8 +384,19 @@ test("the gate asks on an unbound working branch with a person present", () => { }); }); -test("claude-only: every fixture case passes or asks as expected", () => { - for (const [name, c] of Object.entries(cases.claudeOnly)) { +test("hook tool: every fixture case is Claude Code's, Codex's or neither, as expected", () => { + for (const [name, c] of Object.entries(cases.hookTool)) { + const env = c.env_session_id === null ? {} : { CLAUDE_CODE_SESSION_ID: c.env_session_id }; + const payload = { session_id: c.payload_session_id, transcript_path: c.transcript_path }; + for (const key of Object.keys(payload)) if (payload[key] === null) delete payload[key]; + assert.equal(hookTool(payload, env), expected.hookTool[name], name); + // Claude Code's own guard is unchanged, and refuses every case that is not Claude Code's. + assert.equal(isClaudePayload(payload, env), expected.hookTool[name] === "claude", name); + } +}); + +test("hook tool: a Claude Code fixture case asks, any other passes, through the gate", () => { + for (const [name, c] of Object.entries(cases.hookTool)) { withBox({}, (box) => { const env = box.env(); if (c.env_session_id === null) delete env.CLAUDE_CODE_SESSION_ID; @@ -391,31 +405,186 @@ test("claude-only: every fixture case passes or asks as expected", () => { if (c.payload_session_id === null) delete payload.session_id; if (c.transcript_path === null) delete payload.transcript_path; const result = gate(box, { payload, env }); - assert.equal(asks(result), expected.claudeOnly[name], name); - if (!expected.claudeOnly[name]) assertPasses(result); + // The fixture's Codex paths name no file here, so nobody is there to answer. + assert.equal(asks(result), expected.hookTool[name] === "claude", name); + if (expected.hookTool[name] !== "claude") assertPasses(result); }); } }); -test("claude-only: a Codex or Copilot shaped payload passes and writes nothing", () => { +test("hook tool: a Copilot shaped payload passes and writes nothing", () => { withBox({}, (box) => { - const codex = box.payload({ - transcript_path: path.join(box.home, ".codex", "sessions", "2026", "10", "09", `rollout-x-${SESSION}.jsonl`), - }); - assertPasses(gate(box, { payload: codex })); - // a Codex home that happens to sit under a `projects` directory is still Codex - const nested = box.payload({ - transcript_path: path.join(box.root, "projects", ".codex", "sessions", "2026", "10", "09", `rollout-x-${SESSION}.jsonl`), - }); - assertPasses(gate(box, { payload: nested })); - assertPasses(run(START, box, { payload: { ...codex, hook_event_name: "SessionStart", source: "startup" } })); const copilot = { sessionId: SESSION, timestamp: 1, prompt: "x", cwd: box.repo }; assertPasses(gate(box, { payload: copilot })); + assertPasses(run(START, box, { payload: { ...copilot, hook_event_name: "SessionStart", source: "startup" } })); assert.equal(fs.existsSync(path.join(box.tel, "bindings")), false); assert.deepEqual(box.calls(), []); }); }); +// ---------------------------------------------------------------- Codex + +const CODEX_SESSION = "00000000-0000-4000-8000-0000000000dd"; +const TUI = { source: "cli", originator: "codex-tui" }; + +/** The first line Codex writes in a session file, before it hands a hook its path. */ +function sessionMeta(fields = TUI, pad = 0) { + const payload = { id: CODEX_SESSION, cwd: "/work", cli_version: "0.163.0", ...fields }; + if (pad > 0) payload.base_instructions = { text: "x".repeat(pad) }; + return JSON.stringify({ timestamp: "2026-10-19T10:00:00.000Z", type: "session_meta", payload }); +} + +/** A Codex session file whose content is `text` (null: none written). */ +function codexFile(box, text = `${sessionMeta()}\n`) { + const file = path.join(box.home, ".codex", "sessions", "2026", "10", "19", `rollout-2026-10-19T10-00-00-${CODEX_SESSION}.jsonl`); + if (text !== null) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${text}${JSON.stringify({ type: "turn_context", payload: { cwd: "/work" } })}\n`); + } + return file; +} + +/** Codex's hook environment: none of Claude Code's variables, unless a test adds them. */ +function codexEnv(box, extra = {}) { + return box.env({ + CLAUDE_CODE_SESSION_ID: undefined, + CLAUDE_CODE_SESSION_ATTENDED: undefined, + CLAUDE_CODE_ENTRYPOINT: undefined, + CODEX_HOME: path.join(box.home, ".codex"), + ...extra, + }); +} + +function codexPayload(box, transcript, extra = {}) { + return { + session_id: CODEX_SESSION, + transcript_path: transcript, + cwd: box.repo, + hook_event_name: "UserPromptSubmit", + model: "m", + permission_mode: "default", + turn_id: "t", + prompt: "do the thing", + ...extra, + }; +} + +function codexGate(box, { text, payload = {}, env = {} } = {}) { + const file = codexFile(box, text); + return gate(box, { payload: codexPayload(box, file, payload), env: codexEnv(box, env) }); +} + +test("codex presence: a prompt is held when, and only when, every condition holds", () => { + const BOUND = 1024 * 1024; + const meta = (fields) => `${sessionMeta({ ...TUI, ...fields })}\n`; + const rows = [ + ["the TUI", {}, true], + ["the TUI through the app server (source vscode)", { text: meta({ source: "vscode" }) }, true], + ["the TUI started inside Claude Code", { env: { CLAUDE_CODE_SESSION_ID: SESSION, CLAUDE_CODE_SESSION_ATTENDED: "1", CLAUDE_CODE_ENTRYPOINT: "cli" } }, true], + ["a first line well inside the bound", { text: `${sessionMeta(TUI, BOUND / 2)}\n` }, true], + ["codex exec", { text: meta({ source: "exec", originator: "codex_exec" }) }, false], + ["the IDE extension", { text: meta({ source: "vscode", originator: "codex_vscode" }) }, false], + ["the desktop app", { text: meta({ source: "vscode", originator: "Codex Desktop" }) }, false], + ["originator Claude Code", { text: meta({ originator: "Claude Code" }) }, false], + ["originator codex_exec with source cli", { text: meta({ originator: "codex_exec" }) }, false], + ["a sub-agent's file, its source an object", { text: meta({ source: { subagent: { thread_spawn: { parent_thread_id: SESSION, depth: 1 } } } }) }, false], + ["no source", { text: `${sessionMeta({ originator: "codex-tui" })}\n` }, true], + ["no originator", { text: `${sessionMeta({ source: "cli" })}\n` }, false], + ["codex_vscode from the app server", { text: meta({ source: "vscode", originator: "codex_vscode" }) }, false], + ["the desktop app's work originator", { text: meta({ source: "vscode", originator: "codex_work_desktop" }) }, false], + ["a sub-agent's spawn message", { payload: { agent_id: OTHER, agent_type: "default" } }, false], + ["full access (bypassPermissions)", { payload: { permission_mode: "bypassPermissions" } }, false], + ["no permission_mode", { payload: { permission_mode: undefined } }, false], + ["a permission_mode never seen", { payload: { permission_mode: "acceptEdits" } }, false], + ["no session file", { text: null }, false], + ["a first line past the bound", { text: `${sessionMeta(TUI, BOUND)}\n` }, false], + ["a first line that is not JSON", { text: "{torn\n" }, false], + ["a first line that is no session_meta", { text: `${JSON.stringify({ type: "turn_context", payload: TUI })}\n` }, false], + ["an empty file", { text: "" }, false], + ]; + // Every row is judged before failing, so a mutation names all the rows it turns red. + const wrong = []; + for (const [name, row, want] of rows) { + withBox({}, (box) => { + const file = codexFile(box, row.text === undefined ? `${sessionMeta()}\n` : row.text); + if (row.text === "") fs.writeFileSync(file, ""); + const payload = codexPayload(box, file, row.payload); + for (const key of Object.keys(payload)) if (payload[key] === undefined) delete payload[key]; + const result = gate(box, { payload, env: codexEnv(box, row.env) }); + if (asks(result) !== want) wrong.push(name); + else if (!want) assertPasses(result); + }); + } + assert.deepEqual(wrong, []); +}); + +test("codex: the gate asks on an undeclared working branch and keeps the prompt visible", () => { + withBox({}, (box) => { + const result = codexGate(box); + assert.equal( + result.json.reason, + 'Telemetry: no task on feat/x yet. Send "aidd telemetry task [--ticket ]", or "--none", as your next prompt.' + ); + assert.deepEqual(Object.keys(result.json).sort(), ["decision", "reason"]); + }); +}); + +test("codex: a typed declaration binds the payload's session, never an inherited Claude Code one", () => { + for (const inherited of [{}, { CLAUDE_CODE_SESSION_ID: SESSION, CLAUDE_CODE_SESSION_ATTENDED: "1", CLAUDE_CODE_ENTRYPOINT: "cli" }]) { + withBox({}, (box) => { + const result = codexGate(box, { payload: { prompt: "aidd telemetry task fix-cart --ticket PROJ-1" }, env: inherited }); + // Codex was never seen to accept a hidden prompt: the block says the declaration was + // recorded, and nothing else. + assert.deepEqual(result.json, { decision: "block", reason: "Task fix-cart (ticket PROJ-1) declared. Send your prompt again." }); + const declarations = box.calls().filter((c) => c.argv[1] === "task" && !c.argv.includes("--help")); + assert.deepEqual(declarations.map((c) => c.session), [CODEX_SESSION]); + assert.deepEqual(box.bindings("sessions.jsonl").map((d) => d.session_id), [CODEX_SESSION]); + // the next prompt passes: the session is bound + assertPasses(codexGate(box, { env: inherited })); + }); + } +}); + +test("codex: a mistyped declaration is told so, naming Codex", () => { + withBox({}, (box) => { + const result = codexGate(box, { payload: { prompt: "aidd telemetry task fix --ticket" } }); + assert.deepEqual(result.json, { + decision: "block", + reason: 'Not a declaration, nothing sent. Send "aidd telemetry task [--ticket ]" (quote a name with spaces), or "--none". To ask Codex instead, end with "?".', + }); + }); +}); + +test("codex: a declaration typed where nobody can see the answer is not run", () => { + withBox({}, (box) => { + const text = `${sessionMeta({ source: "exec", originator: "codex_exec" })}\n`; + assertPasses(codexGate(box, { text, payload: { prompt: "aidd telemetry task x" } })); + assert.deepEqual(box.calls(), []); + }); +}); + +test("codex: session start does nothing, not even the consent check", () => { + withBox({}, (box) => { + git(box.repo, "config", "--local", "aidd.telemetry", "off"); + const payload = { ...codexPayload(box, codexFile(box)), hook_event_name: "SessionStart", source: "startup" }; + delete payload.prompt; + assertPasses(run(START, box, { payload, env: codexEnv(box) })); + assert.equal(fs.existsSync(path.join(box.tel, "bindings")), false); + assert.equal(consentLines(box).length, 1, "the consent check would close the interval"); + // the same payload through the gate does reach the consent check, which closes it + assertPasses(gate(box, { payload: codexPayload(box, codexFile(box)), env: codexEnv(box) })); + assert.equal(consentLines(box).length, 2); + }); +}); + +test("codex: the catch-up ingests, silently, once", () => { + withBox({}, (box) => { + const payload = { ...codexPayload(box, codexFile(box, null)), hook_event_name: "SessionStart", source: "startup" }; + assertPasses(run(CATCH_UP, box, { payload, env: codexEnv(box, { FAKE_AIDD_NOISE: "1" }) })); + assert.deepEqual(box.calls().map((c) => c.argv), [["telemetry", "ingest", "--quiet"]]); + }); +}); + // The values each surface was seen to hand a hook (2026-10-12), and whether it shows a block's // reason: the terminal and the desktop app do; the VS Code extension sets no ATTENDED and shows // neither a reason nor a systemMessage, so a block there would eat the prompt silently. @@ -780,12 +949,21 @@ test("a typed declaration reaches no model: the CLI stores it, the hook blocks w assert.equal(calls.length, 1); assert.deepEqual(calls[0].argv, ["telemetry", "task", "fix-cart", "--ticket", "PROJ-1", "--by", "hook-intercept"]); assert.equal(fs.realpathSync(calls[0].cwd), box.repo); + assert.equal(calls[0].session, SESSION, "the hook names the payload's session, which the guard made Claude Code's own"); assert.equal(box.bindings("sessions.jsonl").length, 1); // the next prompt passes assertPasses(gate(box)); }); }); +test("a stale AIDD_TELEMETRY_SESSION_ID exported by the user does not win over the session of the prompt", () => { + withBox({}, (box) => { + const env = box.env({ AIDD_TELEMETRY_SESSION_ID: OTHER }); + gate(box, { payload: box.payload({ prompt: "aidd telemetry task fix-cart" }), env }); + assert.deepEqual(box.bindings("sessions.jsonl").map((d) => d.session_id), [SESSION]); + }); +}); + test("a declaration is confirmed in one line, and the person resends the prompt", () => { const rows = [ ["aidd telemetry task fix --ticket P-1", "Task fix (ticket P-1) declared. Send your prompt again."], diff --git a/scripts/__tests__/fixtures/telemetry-bindings/README.md b/scripts/__tests__/fixtures/telemetry-bindings/README.md index ac7678df8..b9022535a 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/README.md +++ b/scripts/__tests__/fixtures/telemetry-bindings/README.md @@ -71,7 +71,10 @@ answer, or an answer with no case, is an error. - `telemetryDir`: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XDG_CONFIG_HOME/aidd/telemetry`, else `/.config/aidd/telemetry`. An empty variable is unset. `win32` cases are joined with backslashes. -- `claudeOnly`: a hook acts only when the payload's `session_id` equals `CLAUDE_CODE_SESSION_ID` - and the payload's `transcript_path`, separators normalised, runs under a `projects` - directory. The CLI has no such guard, so only the hook test executes these cases; the CLI - test checks that they are well formed. +- `hookTool`: which tool's hooks a payload is, `claude`, `codex` or `null` (neither: the hook does + nothing). Claude Code: the payload's `session_id` equals `CLAUDE_CODE_SESSION_ID` and its + `transcript_path`, separators normalised, runs under a `projects` directory and is no Codex + session file. Codex: a `session_id` is set and the `transcript_path` is a Codex session file, + `…/sessions/YYYY/MM/DD/rollout-…`, whatever `CLAUDE_CODE_SESSION_ID` says: a Codex + started inside Claude Code inherits it. The CLI has no such guard, so only the hook test + executes these cases; the CLI test checks that they are well formed. diff --git a/scripts/__tests__/fixtures/telemetry-bindings/cases.json b/scripts/__tests__/fixtures/telemetry-bindings/cases.json index c4269271d..7319a09fa 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/cases.json +++ b/scripts/__tests__/fixtures/telemetry-bindings/cases.json @@ -496,7 +496,7 @@ "env": {} } }, - "claudeOnly": { + "hookTool": { "claude-posix-transcript": { "payload_session_id": "00000000-0000-4000-8000-00000000000a", "env_session_id": "00000000-0000-4000-8000-00000000000a", @@ -551,6 +551,21 @@ "payload_session_id": "00000000-0000-4000-8000-00000000000a", "env_session_id": "00000000-0000-4000-8000-00000000000a", "transcript_path": "/home/someone/.codex/old-projects/rollout.jsonl" + }, + "codex-outside-claude": { + "payload_session_id": "00000000-0000-4000-8000-00000000000a", + "env_session_id": null, + "transcript_path": "/home/someone/.codex/sessions/2026/10/09/rollout-2026-10-09T10-00-00-00000000-0000-4000-8000-00000000000a.jsonl" + }, + "codex-home-under-a-projects-directory": { + "payload_session_id": "00000000-0000-4000-8000-00000000000a", + "env_session_id": "00000000-0000-4000-8000-00000000000a", + "transcript_path": "/work/projects/.codex/sessions/2026/10/09/rollout-2026-10-09T10-00-00-00000000-0000-4000-8000-00000000000a.jsonl" + }, + "codex-payload-has-no-session-id": { + "payload_session_id": null, + "env_session_id": null, + "transcript_path": "/home/someone/.codex/sessions/2026/10/09/rollout-2026-10-09T10-00-00-00000000-0000-4000-8000-00000000000a.jsonl" } }, "consentLog": { diff --git a/scripts/__tests__/fixtures/telemetry-bindings/expected.json b/scripts/__tests__/fixtures/telemetry-bindings/expected.json index 4a11936b4..a18498932 100644 --- a/scripts/__tests__/fixtures/telemetry-bindings/expected.json +++ b/scripts/__tests__/fixtures/telemetry-bindings/expected.json @@ -249,18 +249,21 @@ "win32-xdg": "D:\\xdg\\aidd\\telemetry", "win32-default": "C:\\Users\\someone\\.config\\aidd\\telemetry" }, - "claudeOnly": { - "claude-posix-transcript": true, - "claude-windows-transcript-with-backslashes": true, - "claude-custom-config-dir": true, - "session-id-differs-from-the-environment": false, - "environment-has-no-session-id": false, - "environment-session-id-empty": false, - "payload-has-no-session-id": false, - "codex-rollout-path-with-the-inherited-claude-id": false, - "codex-windows-rollout-path": false, - "payload-has-no-transcript-path": false, - "projects-is-only-part-of-a-name": false + "hookTool": { + "claude-posix-transcript": "claude", + "claude-windows-transcript-with-backslashes": "claude", + "claude-custom-config-dir": "claude", + "session-id-differs-from-the-environment": null, + "environment-has-no-session-id": null, + "environment-session-id-empty": null, + "payload-has-no-session-id": null, + "codex-rollout-path-with-the-inherited-claude-id": "codex", + "codex-windows-rollout-path": "codex", + "payload-has-no-transcript-path": null, + "projects-is-only-part-of-a-name": null, + "codex-outside-claude": "codex", + "codex-home-under-a-projects-directory": "codex", + "codex-payload-has-no-session-id": null }, "consentLog": { "no-file": {