Skip to content

feat(facts): state the pull requests and the real human turns - #113

Merged
drewstone merged 6 commits into
mainfrom
feat/facts-prs-and-human-turns
Sep 10, 2026
Merged

feat(facts): state the pull requests and the real human turns#113
drewstone merged 6 commits into
mainfrom
feat/facts-prs-and-human-turns

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Closes the last measured gap between the free traces facts sheet and an Opus subagent fleet on a private thirteen-session audit battery. Two fields carried the whole gap; both are now computed from spans the runs already wrote.

Measured, before and after

Deterministic mean over the same 13 Codex sessions, same ground truth (two independent extractors that agree exactly), same mechanical mapper. The sheet is free: no model call, no engine, no budget.

Arm dev holdout
traces facts before 0.779 (n=8) 0.789 (n=5)
traces facts after 0.988 (n=8) 0.977 (n=5)
Opus subagent fleet 0.887 (n=8) 0.917 (n=3)
Sonnet subagent fleet 0.880 (n=8) 0.864 (n=3)
model-backed analyst arm 0.492 (n=8) 0.349 (n=4)

Per question (dev / holdout):

Question before after Opus fleet
Which PRs did the agent create and merge? 0.250 / 0.400 1.000 / 0.967 0.958 / 1.000
How many user messages, and what do the first and last say? 0.083 / 0.066 1.000 / 1.000 0.250 / 0.333
Which files did the agent edit? 0.904 / 0.846 0.904 / 0.846 0.889 / 1.000
tool calls, subagents, final message, timestamps, token total 1.000 / 1.000 1.000 / 1.000 1.000 / 1.000

The fleet was not launched on two holdout sessions, so its holdout denominator is 3 against the sheet's 5. Restricted to the sessions both arms attempted, the sheet is 0.988 dev and 0.980 holdout against the fleet's 0.887 and 0.917.

pullRequests

The command spans carry the script, the exit code and the output; nothing was reading them.

  • src/shell-commands.ts splits a script the way a shell would — quoting, comments, redirections and their targets, $( … ), backticks, subshells, nested sh -c, and heredoc bodies. A gh pr create quoted inside a note is not a command that ran; one behind git push && … is.
  • Each entry is named by its number when the command or an output that joins to it shows one, and by its head branch when neither does. A create whose stdout was redirected away takes the number a later output states for that branch on the same line, and names the span that stated it. A failed first attempt folds into the number its retry printed.
  • A failed git push && gh pr create counts only when the output shows gh itself answering; otherwise the shell never reached it.
  • A trace whose spans carry no executed command returns null with that reason. "No pull requests" and "the spans cannot say" are different answers.
  • Every entry keeps the command span id, plus the span whose output supplied the number, and the join evidence in words.

humanTurns, corrected

The sheet counted every span whose actor was human, including the history a forked session copies from its parent and the turns a compaction replays. Eleven of the thirteen sessions are forked subagent runs whose entire user history is inherited, so the sheet reported dozens of turns where the truth is zero.

Three filters now run in order, and every excluded span is listed in the new excludedTurns with its reason and span ids — nothing is dropped silently:

  • history the session carries but did not receive (a fork prefix, a compaction replay);
  • turns whose actor is not a person — an instruction file, an environment-context block, a system reminder, a subagent notification, a turn-aborted marker, a skill or slash-command expansion;
  • a second record of the turn before it: the same text at the same instant, with no model call, tool call or assistant message between them.

For Codex the actor now comes from the harness's own per-item labelling (internal_chat_message_metadata_passthrough.content_item_kinds) whenever the record carries it: a message is the person's exactly when every item in it is a user. kind. That is what the log says happened, rather than what the text looks like.

The de-duplication rule demands an identical instant on purpose. A looser rule — same text, nothing in between, inside a window — collapsed a measured "continue, continue" typed 1.8 s apart while the agent was mid-turn, and cost a whole session's count. Record-level pairing, which can see which log each record came from, stays the adapter's job.

Tests

New synthetic fixtures, inline JSONL in the repo, nothing from any recorded session:

  • tests/session-facts-pull-requests.test.ts — a create inside a multi-call script whose head branch comes from the git push beside it, one through gh-drew, one whose number appears only in a later gh pr list output, a merge inside a verification script, a heredoc body that only talks about gh pr create, a failed push that never reached gh, a retried create counted once, and a trace with no command spans returning null.
  • tests/session-facts-human-turns.test.ts — an injected AGENTS.md block, an environment-context block, one turn logged twice, a duplicate the adapter did not pair, a message queued twice 1.8 s apart that stays two turns, a forked session's inherited prefix, and Codex's labelling outranking the first-turn text heuristic.

Also in this branch

This stacks on #112 and merges #111 (the command spans and the token attribute). Two defects of that merge are fixed here, both of which failed on the merge base before any change of mine:

  • the sheet tested for the pre-rename traces.codex.span_synthesized marker while the adapter had moved to traces.span.synthesized; six tests/session-facts.test.ts assertions were stale;
  • tests/cli.test.ts gave itself a 15 s budget for three subprocesses each allowed 30 s, which is the exact disagreement vitest.config.ts raised the default to fix.

pnpm check:source, pnpm typecheck, pnpm test (890 tests), pnpm build and pnpm check:package are green.

What still blocks parity

changedFiles is unchanged at 0.904 dev / 0.846 holdout, against the fleet's 0.889 / 1.000, and is now the only question below the fleet. The cause is specific: the sheet recovers paths from apply_patch headers in input.value, so template literals such as ${path} come through unresolved and a patch written by a code-mode script is largely missed — while the file.change spans #110 added already carry the harness's own resolved paths and are not read at all. On one holdout session the file.change spans name exactly the 15 paths the ground truth lists, against the sheet's 12 plus one bogus ${currentPath}.

🤖 Generated with Claude Code

drewstone and others added 6 commits September 10, 2026 01:07
`traces ask` and the exported `runTraceQuestions` run one or many questions
over one or many sessions. Each question is its own `runTraceAnalyst` call,
so the engine's prose answer survives; the analyst registry keeps only
findings and runs analysts one at a time.

- Questions run concurrently through a worker pool with a configurable limit.
  Unlike the import pool, one failed question never stops the others.
- One shared `CostLedger` bounds the whole run, so `--budget` means the same
  thing whatever the number of questions; `--question-budget` bounds one
  question. A budget below one call's reservation refuses the run before any
  model call, and a budget that serializes the pool warns instead.
- Every `trace://` citation in an answer is resolved against the store; an
  unresolvable citation fails that question.
- A question may fix its answer's shape with a small JSON Schema subset. An
  unsupported keyword is rejected rather than ignored.
- The question layout fits the DSPy input preview: the question stays whole
  and the answer rules sit in the first 500 characters of the instructions.
- Output is `answers.json` and `report.md`, written before the exit code is
  decided; exit 1 when any question failed.

Also surface what the evidence gate refused, and stop reporting a failed
external analyzer as success:

- `finding rejected` log lines now name the reason and the offending URI,
  and rejection counts by reason reach the analyst table's Detail cell,
  `TraceInvestigationResult.findingRejections`, and the ask JSON.
- `analyze` exits 1 when a requested `--analyzer` fails, after writing the
  report that holds its error.

The `--llm` engine construction moves to `analysisEngineFromEnv` in
`analyst-model-call.ts`, and the shared trace-file and store setup to
`analysis-store.ts`, so `ask` reuses both instead of duplicating them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit question asks which commands ran, what they exited with, when they
ran, which files changed, and what the person actually typed. The Codex
adapter dropped every `item_completed` event, so none of those facts reached
a span, and it labelled harness-injected context as a human turn.

- Emit one CHAIN span per `CommandExecution` item (command, cwd, exit code,
  process id, output, and the item's own start and end times) and one per
  changed path in a `FileChange` item. Each item joins the one tool call whose
  window contains its whole run; an item that outlives every call or falls
  inside two stays under the session root and says so. Item shapes the adapter
  cannot represent are counted on the root, never guessed.
- Mark the inner spans `traces.tool_call.level=inner` so tool-call counts,
  loop detection and conversation text keep reading the model-issued level.
- Accept a script receipt whose "Wall time" line has no colon, which left
  script outcomes UNSET.
- Treat Codex's context blocks (`<environment_context>`, `<user_instructions>`,
  `<skills_instructions>`, the `<external_*>` wrappers, the injected warnings
  and the rest of `CONTEXTUAL_USER_FRAGMENT_MATCHERS`) as injected, and take
  the human turn from the record Codex writes for submitted input: the legacy
  `user_message` event or the current `item_completed`/`UserMessage` item,
  paired with its response-item copy so one turn stays one span.
- Drop the parent session from `childSessionIds`: a child that messages its
  parent named it as a `send_message` target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… as tool calls

Three adapter facts an audit question asks for, measured against twelve
private Codex sessions where a mechanical extraction from the spans this
adapter already writes scored 0.858 and the analyst arm scored 0.389.

1. The cumulative token total. Codex reports
   `token_count.info.total_token_usage` beside the per-turn delta, and the
   adapter used it only as a de-duplication signature, so no span held the
   session total. The last snapshot equals ground truth on 13 of 13 sessions;
   summing the deltas or the snapshots does not. It is copied onto the root
   span verbatim, with the rest of the same snapshot beside it.

2. The synthesized subagent span. `ensureSubagentSpan` built a child thread's
   lifecycle as a TOOL span named `tool.Agent`, so every tool-call count ran
   high by one per child thread (TOOL minus `tool.Agent` reproduced truth on
   12 of 12 sessions). It is now an AGENT span named `subagent.lifecycle`,
   with no `tool.name` and `traces.span.synthesized = true`; the evidence,
   live, pipeline, run-tree and adoption paths count model-issued calls only.

3. The inherited prefix. The task-scope walk dropped every record before the
   fork boundary, and `compacted` records were never parsed, so a forked
   child's human context reached no span. Both are kept as spans marked
   `traces.session.inherited = true`, deduplicated on the turn text and
   bounded per session, with what the cap drops counted on the root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bounded trace tools cannot return a whole-session fact. `viewTrace`
degrades to a 20-entry name histogram above its 150,000-byte ceiling,
`countTraces` counts traces rather than spans, `viewSpans` needs span ids
the reader does not have, and `searchTrace` stops at 500 hits. A model
asked for a tool-call total therefore adds up a capped histogram and
decides by eye which names count.

Measured over twelve private audit sessions, the model-backed analyst arm
scored a deterministic mean of 0.389. Extracting the same answers
mechanically from the OTLP spans those runs already wrote scores 0.858:
the facts were present and exact the whole time.

This adds that extraction as `src/session-facts.ts`, a `traces facts`
command that prints it at $0, and the same sheet as prepared context for
the built-in analyst kinds and every `traces ask` question.

Two rules hold for every field: it names the span ids it was computed
from, so a reader can check it; and a fact the spans cannot support is
null with a stated reason, never a guess and never a silent zero. The
sheet is not a span and cannot be cited — citations still resolve against
the raw spans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sized-spans' into feat/facts-prs-and-human-turns
Two fields carried the whole remaining gap between the free `traces facts`
sheet and a subagent fleet on a private thirteen-session audit battery: the
sheet computed no pull-request facts at all (0.31), and it counted every
human-role span as a turn (0.076), including the history a forked session
copies from its parent and the turns a compaction replays.

`pullRequests` reads the command spans. `src/shell-commands.ts` splits a
script the way a shell would — quoting, comments, redirections, command
substitutions, nested `sh -c`, and heredoc bodies — so a `gh pr create`
quoted inside a note is not a command that ran, and one hidden behind
`git push && …` is. Each entry is named by its number when the command or an
output that joins to it shows one, and by its head branch when neither does;
a create whose stdout was redirected away takes the number a later output
states for that branch and names the span that stated it. A trace whose
spans carry no executed command returns null with that reason, because "no
pull requests" and "the spans cannot say" are different answers.

`humanTurns` now counts turns a person typed into THIS session. Inherited
fork prefixes and compaction replays, harness-injected blocks, and a second
record of the same turn are excluded, and every excluded span is listed in
the new `excludedTurns` with its reason — nothing is dropped silently. For
Codex the actor comes from the harness's own per-item labelling
(`content_item_kinds`) whenever the record carries it, rather than from what
the text looks like. The de-duplication rule demands the same text at the
same instant with no agent activity between: a measured "continue, continue"
typed 1.8 s apart is two turns, and collapsing it cost a whole session's
count.

Also fixes the merge of the two open branches this stacks on: the sheet was
testing for the pre-rename `traces.codex.span_synthesized` marker while the
adapter had moved to `traces.span.synthesized`, and `tests/cli.test.ts` gave
itself a 15 s budget for three subprocesses each allowed 30 s.

Measured over the same thirteen Codex sessions, deterministic mean per split:
dev 0.779 -> 0.988, holdout 0.789 -> 0.977, against an Opus subagent fleet at
0.887 and 0.917. Per question: pull requests 0.25/0.40 -> 1.000/0.967, human
turns 0.083/0.066 -> 1.000/1.000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@drewstone
drewstone changed the base branch from feat/session-facts to main September 10, 2026 18:15
@drewstone
drewstone merged commit aec95a3 into main Sep 10, 2026
2 checks passed
drewstone added a commit that referenced this pull request Sep 10, 2026
feat(facts): land the session-facts audit stack (#108, #111, #112, #113, #114)
@drewstone

Copy link
Copy Markdown
Contributor Author

Landed on main in d96f933a — the merge of #115, which brought the whole audit-facts stack (#108's ask groundwork, #111, #112, #113, #114) across on top of main's reviewed #110. This PR's head commit aec95a3 is an ancestor of origin/main.

The conflict resolution against main's reviewed #110 is 2ee1e63; pnpm check:source, typecheck, test (983), build and check:package are green on the merged code, and the deterministic facts arm reproduces dev 1.000 / holdout 0.996 over the 13 Codex bench sessions.

@drewstone
drewstone deleted the feat/facts-prs-and-human-turns branch September 10, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant