Skip to content

Trace a PR back to its session

CLIAPIIntermediate~10 min

Someone asks "who wrote this, and why?" about a merged PR. This is the question the whole Worker model exists to answer. It takes three lookups — the worklog, then the session, then the person — and this page walks them.

You need a team App with a worklog — that is, somebody recorded the work as it happened (Set up an AI team). The worklog is append-only, so anything recorded is still there.

Step 1 — Ask the worklog who produced the artifact

hadron team session list --pr hadron-memory/hadron-docs#271
WORKER  ROLE           USER              TOOL  HOST  MODEL          STARTED                   TRANSCRIPT  SESSION
Tove    docs-engineer  019d28f166d979…   —     —     claude-opus-5  2026-08-25T08:29:43.359Z  —           01a0380a222c73af900eced52612c3bf
hadron_team_work_items(ref: "hadron-memory/hadron-docs#271", kind: "pr")
1 record(s) total, showing 1:

2026-08-26T10:16:40.563Z [Tove] pr opened hadron-memory/hadron-docs#271
  (session 01a0380a222c73af900eced52612c3bf)
query($app: ID!, $ref: String!) {
  teamWorkItems(appRef: $app, ref: $ref, kind: "pr") {
    total
    items { workerName sessionId kind action at }
  }
}

Spelling does not matter; the artifact KIND does. A PR can be written as a GitHub URL or as owner/repo#N — both normalize to the same record, so https://github.com/hadron-memory/hadron-docs/pull/271 and hadron-memory/hadron-docs#271 are interchangeable.

owner/repo@sha and owner/repo:branch are different artifacts, not other ways of writing a PR. On the CLI each kind has its own flag — --commit, --branch, --issue — and passing a commit ref to --pr finds nothing.

And owner/repo#N does not distinguish a PR from an issue. PRs and issues share GitHub's number space, so they share a canonical spelling and a ref-only lookup finds both. The CLI's --pr already says which you mean; on the other two surfaces, pass kind: "pr" — otherwise issue #271 comes back alongside PR #271.

Several rows are expected and correct. A PR spanning three sessions yields three transcripts. A recorded session you cannot read appears as an id-only stub rather than vanishing, so the count never silently understates the work.

But the rows answer "who touched this", not "who wrote it". Every record against the ref comes back — opened, reviewed, merged, closed — so a worker who only merged the PR appears beside the one who wrote it. The distinguishing field is action, which the MCP and GraphQL forms show and the CLI's human table does not: reach for --json when the difference matters.

Step 2 — Read the session for the rest

Step 1 gives you a session id. Turning that into the rest of the provenance takes the CLI or the GraphQL API — --json on the command above, or:

query($id: ID!) {
  session(id: $id) {
    workerName workerRole userId
    repo branch prNumber startedAt
    host tool transcriptPath llmModel
  }
}

MCP cannot read an arbitrary session

There is no MCP tool for it. hadron_whoami returns your own sessions, so it cannot open a colleague's. An MCP-only host can do step 1 and step 2b — which worker, which session, and which person — but the session's own fields need the CLI or the API. That is why this page is badged cli / api rather than mcp.

--json gives you everything the bind recorded:

{
  "workerName": "Tove",
  "workerRole": "docs-engineer",
  "userId": "019d28f166d979d09438cd92e832194c",
  "repo": "hadron-memory/hadron-docs",
  "branch": "main",
  "prNumber": 271,
  "startedAt": "2026-08-25T08:29:43.359Z",
  "host": null,
  "tool": null,
  "transcriptPath": null,
  "llmModel": "claude-opus-5"
}

That chain — PR → worklog → session → transcript — is the provenance the Worker: commit trailer promises. How much of its last link you actually get depends on what the bind recorded, which is the next section.

Step 2b — Turn userId into a person

userId is an opaque id, and "who drove it" is usually the point. Resolve it:

hadron_search(query: "019d28f166d979d09438cd92e832194c", entityTypes: ["user"])
1 result for "019d28f166d979d09438cd92e832194c":

- [user] hrn:user:holger — Holger Selover-Stephan (matched pk, score 3.00)
query($ref: ID!) { user(ref: $ref) { urn handle name } }

user(ref:) takes the id or the URN, so this is also the step that turns a hrn:user:… you already have into a display name.

The provenance you get out is the provenance that went in

Look again at the sample above: tool, host and transcriptPath are null. Nothing is broken. That session was bound from an MCP host, where those are not passed, so they were never recorded.

None of them are backfilled, and none can be reconstructed later. If you want a transcript path in the answer, it has to be supplied at bind time:

hadron team session start --as Tove --tool claude-code \
  --host "$(hostname)" --transcript ~/.claude/projects/…/session.jsonl

So the useful habit is to decide before a stint what the person asking in six months will need, rather than discovering the gap while answering them.

The other direction — everything one worker has done

The same worklog answers the reverse question, which is often the one you actually have:

hadron_team_work_items(worker: "Tove")
11 record(s) total, showing 8:

2026-08-26T10:16:40.563Z [Tove] pr opened  hadron-memory/hadron-docs#271
2026-08-26T09:55:22.684Z [Tove] pr merged  hadron-memory/hadron-docs#270
2026-08-26T09:35:36.593Z [Tove] pr opened  hadron-memory/hadron-docs#270

A retired worker still resolves: retirement ends the casting, not the history.

Mind the envelope. The sample says 11 records total, showing 8 — the response is a page, not the set. total is the number to compare against, and limit / offset are how you walk the rest. "Everything one worker has done" is what the query means, not what one call returns.

Each step is gated more narrowly than the last

The three lookups do not share one permission, so a chain that starts fine can stop partway — and it stops by returning less, not by refusing:

Step Who can do it
1 — the worklog Any AppMember (any role), an org CONTRIBUTOR+, a user-owned App's owner, or the App's own key
2 — read the session The attributed driver, a member of the owning org, the session's App, or a platform admin
2b — name the person A caller sharing an organization with them, or a platform admin — anyone else gets null

So an AppMember who is neither the driver nor in the owning org sees the work happened and gets the id-only stub described above — that stub is this gate, not a missing record. And an org member tracing a PR driven by an external App member can open the session but cannot resolve the userId.

Neither is a bug to route around. If you need the answer and the gate says no, the route is to ask someone who has it, not to widen your own access.

Why Session.prNumber is not the index

A session row carries a prNumber, and it is tempting to query on it. Don't.

It is a denormalized display field — the convenience that lets a session list show a PR number without a join. Each pr record overwrites it, so it holds the most recent PR that session touched, not all of them. In the JSON above it reads 271 because that was the last one; the same session also produced #265, #266, #267, #268 and #270, and only the worklog knows that.

The worklog is the many-to-many join. prNumber is a label on one end of it.