Skip to content

fix(framework): the run journal never writes on Copilot #681

Description

@blafourcade

The breakage

On Copilot, detectHost() returns null for the framework's own hooks, so the run journal never writes a line.

The chain, each link read from source:

  1. flattenCopilotHooksShape passes Claude's PascalCase event keys through verbatim - cli/tests/domain/formats/flat-hooks-merge.unit.test.ts:80-95 asserts result.hooks.PreToolUse.
  2. Copilot accepts PascalCase as an alias but rewrites it to its canonical camelCase name and stamps the entry _vsCodeCompat (app.js, tpe()).
  3. _vsCodeCompat selects a different payload builder. The compat base is { hook_event_name, session_id, timestamp, cwd }, snake_case; the canonical base is { sessionId, timestamp, cwd }, camelCase.
  4. plugins/aidd-telemetry/hooks/lib/host.js detects Copilot as "has sessionId AND has no hook_event_name". A compat payload has neither property: it has session_id, and it does have hook_event_name.
  5. No other branch matches (transcript_path is absent), so detectHost() returns null and processPayload returns before touching the journal.

Bounds, stated plainly

Established by reading @github/copilot@1.0.57's bundle. The runtime on the test machine reports 1.0.80, and its binary is packed and not readable. This is not confirmed against a real payload.

What settles it: install the framework's .github/hooks/*.hooks.json in a repo, run a Copilot session, dump the hook's actual stdin, and read which key set arrives.

Done when

  • A real Copilot hook payload is captured and the key set is recorded in a fixture.
  • detectHost() recognises whichever shape actually arrives, and a test fails if it stops doing so.
  • If both shapes can arrive, both are recognised rather than one being assumed.

Also worth knowing

Copilot defers project-scope hooks past session creation. In non-interactive prompt mode the deferred load runs only when the folder is trusted or COPILOT_ALLOW_ALL=true / GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS=true is set. Whether a project-scope SessionStart hook still fires after a deferred load is not established, and the journal's first line depends on it.

Relations

Field Value
related #617, #663

Activity

  1. blafourcade commented on Aug 20, 2026

    @blafourcade
    ContributorAuthor

    Narrowing this, from a captured payload rather than from source.

    A real Copilot hook payload, from a probe whose hooks file declared canonical camelCase event names:

    {"sessionId": "b349a8c0-…", "timestamp": 1787047151787, "cwd": "…", "toolName": "skill", "toolArgs": "{\"skill\":\"probe-echo\"}"}

    sessionId, no hook_event_name - exactly what detectHost() looks for. So the canonical path works today.

    The framework declares PascalCase, which Copilot accepts as an alias but rewrites while tagging the entry _vsCodeCompat, switching it to the snake_case payload. That path is still uncaptured, so the defect stands unconfirmed - but the fix is probably one declaration rather than a change to host detection: emit the canonical camelCase names for Copilot and the existing detection holds.

    Capture the PascalCase-declared payload before choosing between the two.

  2. moved this from Ideation to Todo in AIDD Roadmapon Aug 20, 2026
  3. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    Confirmed against a real payload, on 1.0.80

    The reasoning in this ticket was read out of a 1.0.57 bundle and had never met a payload. It has now. A dumping hook was registered as an additional file in ~/.copilot/hooks/ — nothing else in that directory was touched — and one session was run.

    SessionStart, as it actually arrives:

    {
      "hook_event_name": "SessionStart",
      "session_id": "9410fe62-…",
      "timestamp": "2026-08-21T14:07:46.858Z",
      "cwd": "/private/tmp/cop",
      "source": "new"
    }

    The compat shape, exactly as reasoned: session_id in snake_case, and hook_event_name present. detectHost recognises Copilot as has sessionId and has no hook_event_name, so it answers null and processPayload returns before the journal is touched.

    Every link in the chain above holds. The fix is the one this ticket describes.

    Two things the capture adds.

    SessionStart also carries initial_prompt — the user's own text. Nothing here reads it and nothing should, but a detector that starts logging payloads for diagnosis would be logging prompts.

    Stop carries a transcript_path, which the earlier reading did not predict:

    {
      "hook_event_name": "Stop",
      "session_id": "9410fe62-…",
      "transcript_path": "~/.copilot/session-state/9410fe62-…/events.jsonl",
      "stop_reason": "end_turn"
    }

    That matters twice. It is a second payload shape carrying transcript_path, so whatever detectHost does must not confuse it with Claude Code (/projects/…​.jsonl) or Codex (/sessions/YYYY/MM/DD/rollout-…); it matches neither today, and a test should say so rather than leaving it to luck. And it points at a file this repository has never read — see the finding filed separately.

    Still open: whether a project-scope SessionStart fires when hooks are deferred past session creation. This capture used a user-scope hook and COPILOT_ALLOW_ALL=true, so it does not answer it.

  4. blafourcade commented on Aug 21, 2026

    @blafourcade
    ContributorAuthor

    Captured, and the chain read from source was right

    @github/copilot@1.0.80, one real session, the framework's own installed plugin. All three hooks fired — no deferral, no trust prompt, nothing to lift:

    SessionStart  { hook_event_name, session_id, timestamp, cwd, source, initial_prompt }
    PostToolUse   { hook_event_name, session_id, timestamp, cwd, tool_name, tool_input, tool_result }
    Stop          { hook_event_name, session_id, timestamp, cwd, transcript_path, stop_reason, stop_hook_active }
    

    That is the _vsCodeCompat shape this ticket predicted, down to the spelling: snake_case session_id, hook_event_name present and carrying Claude Code's own event names, no sessionId anywhere. Against the old detectHost() all three returned null, so the run journal wrote nothing — confirmed by replaying the untouched captures through journal.js's real stdin path.

    One fact the ticket did not have: both shapes are real. The canonical camelCase payload from the #663 probes is also a genuine capture, so recognition was added rather than swapped. The ticket's third Done-when — "if both shapes can arrive, both are recognised" — is the case that actually applies.

    What tells them apart

    timestamp, together with hook_event_name and session_id. No other host's payload has ever carried timestamp, across every fixture in the repository, and the branch sits after the two transcript_path patterns so a host that does carry one is claimed by its own shape first. Verified across all seventeen fixtures: every one still detects as the host it came from, Copilot's Stop event included, and that one does carry a transcript_path.

    record.js now reads payload.sessionId ?? payload.session_id for Copilot — both spellings are Copilot's own, neither is a fallback guess.

    Left alone, deliberately

    docs/telemetry-limits.md is untouched. The captured session used a Bash tool only, so nothing in it bears on what the doc says about per-step breakdown. Editing it on this evidence would be the guess this milestone exists to prevent.

    The gap this opens

    lib/step-starts.js reads Copilot's skill invocation as toolName / toolArgs — the canonical spelling. A compat PostToolUse carries tool_name / tool_input, so on that shape no step line opens: the session journals, and every record in it reads unattributed. Filed separately, because closing it needs a capture this one does not provide — a Copilot session that actually invokes a skill, to learn what the compat builder calls that tool and where it puts the skill name. Guessing those two field values would be the same mistake this ticket was opened to correct.

    Gate

    node --test scripts/__tests__/aidd-telemetry-journal.test.js — 159 pass, 0 fail, 13 of them new: fixture-based recognition of both shapes, a guard proving the compat rule does not over-claim a Codex or Claude Code payload, the three untouched captures replayed end to end, and a pair showing a recognised payload writes a run file where one missing only timestamp writes none.

  5. moved this from Todo to In review in AIDD Roadmapon Aug 22, 2026
  6. blafourcade commented on Aug 22, 2026

    @blafourcade
    ContributorAuthor

    Done and awaiting review in #706, which closes this on merge.

    The work is on claude/aidd-telemetry-layer-e403uf, eleven commits, targeting next. Gate at the time of push: 365 plugin specs, 1,931 CLI unit, 577 integration, 178 e2e, tsc and biome clean, no broken markdown links.

    Read the eleven commits rather than the pull request's file count — the branch carries its own copy of the CLI migration that next has since received, so the diff counts it twice. The range and the real figures are in the first comment on #706: 142 files, +10,518 −418.

    Every claim about a tool in this work rests on a session that was actually run. The negative results are kept in full in aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/measurements.md, including the two probes that disagreed about OpenCode and the measurement that reconciled them.

  7. blafourcade commented on Sep 2, 2026

    @blafourcade
    ContributorAuthor

    Delivered by #706, squash-merged into next as 627408f.

  8. moved this from In review to Done in AIDD Roadmapon Sep 2, 2026
  9. added theissue type on Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Fields

    Priority

    High

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions