Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ before your first edit, then `CONTRIBUTING.md`.

1. **`CONTRIBUTING.md`** — the three non-negotiable rules, code conventions, testing policy
2. **`docs/architecture.md`** — how the pieces fit, including the module-level engine handle
3. **`docs/tools.md`** — the 16-tool contract
3. **`docs/tools.md`** — the 17-tool contract
4. **`docs/agent-legible-dom.md`** — only if you touch `lib/dom`

## One area per change
Expand All @@ -28,15 +28,15 @@ than none. Whoever asked you to make this change will tell you your scope.

One stub is left, and it still carries its marker. `registerDynamicTool` in
`traces/src/lib/webmcp/register-tools.ts:133` throws `registerDynamicTool: not implemented`, and nothing
in the codebase calls it — so promoting a hypothesis does not grow a 17th tool. The convention is
in the codebase calls it — so promoting a hypothesis does not grow an 18th tool. The convention is
documented here because the rule outlives the markers: a marker names the person who owned the work and
the day it was due, so **deleting one while implementing around it destroys the only record of who owes
what.** If you add a marker, name yourself in it. If you find one, either implement it or leave it
exactly where it is.

## The tests are green, and two suites must stay honest

All 303 tests pass. That is worth stating because of how some of them got there: the `compress-dom`,
All 309 tests pass. That is worth stating because of how some of them got there: the `compress-dom`,
`bisect` and `evaluatePredicate` suites were written first, as specifications, and were red for as long
as it took the implementations to satisfy them. **Never** make a test in those suites pass by weakening
an assertion, adding `.skip`, or deleting a case: that converts a specification into a lie, silently. A
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ That's the gap Traces fills.

## What it does

Traces loads an rrweb recording in the browser and registers **16 WebMCP tools** on
Traces loads an rrweb recording in the browser and registers **17 WebMCP tools** on
`document.modelContext`. An agent connected to the page can then read the session, search across
time, take actions on the timeline, and — this is the part we care most about — **ask the human
questions**.
Expand Down Expand Up @@ -184,7 +184,7 @@ the design better.

---

## The 16 tools
## The 17 tools

Full contracts, argument shapes, and edge-case behaviour in **[docs/tools.md](docs/tools.md)**.

Expand All @@ -206,6 +206,7 @@ Full contracts, argument shapes, and edge-case behaviour in **[docs/tools.md](do
| 14 | `propose_report` | blocking | a bug report draft the human edits and approves |
| 15 | `claim_next_task` | blocking | pull the next task from the agent lane |
| 16 | `snapshot_finding` | write | save a finding |
| 17 | `read_markers` | read | every marker on the timeline, the human's included |

Four of them **block**: `execute()` does not resolve until a person acts. Because a call can't hang
forever, each one returns `{ status: "pending", ticket }` on timeout instead of leaving the agent
Expand Down Expand Up @@ -374,7 +375,7 @@ goes nowhere; there is no upload endpoint to send it to.
Traces/
├── docs/
│ ├── architecture.md how it's put together, and why
│ ├── tools.md the 16-tool contract
│ ├── tools.md the 17-tool contract
│ ├── agent-legible-dom.md the DOM compressor spec
│ └── threat-model.md what we defend against
├── traces/ the app — this is the deployed URL
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Traces/
├── CONTRIBUTING.md
├── docs/
│ ├── architecture.md this file
│ ├── tools.md the 16-tool contract
│ ├── tools.md the 17-tool contract
│ ├── agent-legible-dom.md the DOM compressor spec
│ └── threat-model.md
├── traces/ the app itself — the deployed URL
Expand Down Expand Up @@ -84,7 +84,7 @@ traces/
│ │ ├── register-tools.ts every registerTool call, one place
│ │ ├── blocking.ts the human-in-the-loop gate
│ │ ├── polyfill.ts
│ │ └── tools/ one file per tool, 16 of them, plus index.ts and registry.test.ts
│ │ └── tools/ one file per tool, 17 of them, plus index.ts and registry.test.ts
│ ├── store/session.ts single state store
│ └── report/build-report.ts reconstructs steps from real events
└── types/
Expand Down
49 changes: 47 additions & 2 deletions docs/tools.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Tool reference

Traces registers **16 WebMCP tools** on `document.modelContext`. This document is the contract: what
Traces registers **17 WebMCP tools** on `document.modelContext`. This document is the contract: what
each tool takes, what it returns, and the rules an agent needs to know to use it correctly.

Every tool returns `{ content: [{ type: "text", text: ... }] }`. The WebMCP spec currently defines
Expand Down Expand Up @@ -39,6 +39,7 @@ bending the type, and those additions are listed per tool.
| 14 | `propose_report` | **blocking** | a bug report draft the human edits and approves |
| 15 | `claim_next_task` | **blocking** | pull the next task from the agent lane |
| 16 | `snapshot_finding` | write | save markers, hypotheses and the report draft |
| 17 | `read_markers` | read | every marker in a window, the human's included, rejections flagged |

**Blocking** means exactly what it says: `execute()` does not resolve until a person acts, or until
the gate's timeout hands back a ticket. The agent's own loop waits. See
Expand All @@ -48,7 +49,7 @@ the gate's timeout hands back a ticket. The agent's own loop waits. See

## Conventions every tool follows

Stated once here rather than repeated sixteen times.
Stated once here rather than repeated seventeen times.

- **Times are milliseconds from the start of the recording**, `0..durationMs`, never epoch. Call
`read_session_meta` first to learn `durationMs`.
Expand Down Expand Up @@ -703,6 +704,50 @@ findings are still on screen and the message says to copy them out of the panel.

---

## Reading the investigation

### 17. `read_markers({ from?, to? })`

```
input: { from?: number, to?: number }
output: { fromMs, toMs,
markers: [{ id, atMs, label, severity, author, rejected? }],
totalMatched, humanCount, agentCount, truncated, note? }
```

The read half of `annotate` (§11), and it is numbered last because it was built last, not because it
is a saving tool — every other read tool answers a question about the *recording*, this one answers a
question about the *investigation*.

**It returns the human's markers as well as the agent's**, which is the reason it exists.
`ask_human_visual` (§12) lands the human's answer on the timeline as a marker precisely so it is
evidence anyone can click; without this tool "anyone" excluded the agent, which could write markers
and never read one back. A session resumed after a reload, or picked up by a second agent, started
blind to the moments a person had already pointed at.

`author` is the frozen `Author` — `"human"` or `"agent"` — and `humanCount`/`agentCount` are counted
over everything the window matched, not over what survived the cap.

**A rejected marker comes back flagged, not filtered.** `rejected: true` means the human dismissed
it; the field is *absent* rather than `false` on a marker that stands, so it is not noise on every
entry. Filtering them out would leave an agent free to re-propose exactly what a person has already
thrown away.

- Capped at 40 markers, the **human's kept ahead of the agent's** when the cap bites, then
chronological order restored. The agent already holds the ids of everything it pinned itself, from
`annotate`'s replies, so its own are the ones it can most afford to lose. `annotate` caps the agent
at 40 markers on one timeline, so the total can exceed 40 only once a human has marked as well.
- Returned in timeline order, not the order the markers were made in. Markers are added at any
timestamp at any time, and a timeline is read as a sequence.
- `label` is capped at 80 characters, the same ceiling `annotate` and `ask_human_visual` enforce when
writing.
- An empty result carries a note saying so in words. "No markers in this window" and an empty list
are the same fact, but only one of them survives being skimmed.
- **A loaded recording is required**, like every other read tool. A marker is a timestamp into a
recording, and answering "no markers" with nothing loaded would read as "the human marked nothing".

---

## Predicates

Predicates are a **closed, validated set of structured objects**. Traces never evaluates a string
Expand Down
2 changes: 1 addition & 1 deletion traces/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const nextConfig = {
* Origin isolation is not optional, and it is not part of the trial: WebMCP refuses to register a
* tool unless the document is origin-isolated, so this header ships whether or not a token is
* configured. Sending only `Origin-Trial` produces the worst available failure — `document
* .modelContext` exists, so the banner reports `native` in green, while all sixteen
* .modelContext` exists, so the banner reports `native` in green, while all seventeen
* `registerTool` calls throw and `registerTools` returns an empty array. The page looks healthy
* and nothing on it is agent-callable.
*
Expand Down
2 changes: 1 addition & 1 deletion traces/src/app/tool-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function ToolSurface() {
* `registerTools` is async because the spec's `registerTool` rejects rather than throws. The flag
* is what keeps React 19's double mount honest: the first pass is aborted on cleanup and resolves
* with nothing registered, and without this guard that empty result can land after the second
* pass's real one and grey out a banner over sixteen live tools.
* pass's real one and grey out a banner over seventeen live tools.
*/
let active = true

Expand Down
2 changes: 1 addition & 1 deletion traces/src/components/agent/agent-lane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { Task, TaskStatus } from '@/types/domain'
* A human types a task; the agent picks it up by calling `claim_next_task`, which blocks until one
* exists. That inversion is the interesting part — the agent waits on the person rather than the
* person waiting on the agent — and it means the lane is not a UI convenience, it is the queue that
* one of the sixteen tools reads from.
* one of the seventeen tools reads from.
*
* Show `claimed` distinctly from `open`. Watching a task flip to claimed a second after you typed it,
* with no click in between, is the clearest demonstration in the whole app that something else is
Expand Down
2 changes: 1 addition & 1 deletion traces/src/components/player/stage-empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function StageEmptyState() {
Getting WebMCP
</h3>
<p className="mt-1 text-meta leading-relaxed text-muted">
WebMCP is how an agent finds the sixteen tools on this page. Turn it on in ChatGPT Desktop or
WebMCP is how an agent finds the seventeen tools on this page. Turn it on in ChatGPT Desktop or
Chrome, then the header pill should read live. Replay still works without it — only the agent
needs the tools.
</p>
Expand Down
6 changes: 3 additions & 3 deletions traces/src/components/ui/webmcp-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { RegistrationResult } from '@/lib/webmcp/register-tools'
*
* - **The tool list comes from the host, not from us.** `document.modelContext.getTools()` reports what
* the browser actually holds, so a tool the host rejected cannot appear here. Reading our own
* `allTools` array instead would render sixteen confident cards on a page where zero are callable.
* `allTools` array instead would render seventeen confident cards on a page where zero are callable.
* - **The panel is a dropdown, not a dock.** Opening it from the header means it can be dismissed, so
* the old rule that a red panel had no close button does not apply: the thing that must not be
* hideable is the banner row, and that row is still in flow with no close control.
Expand Down Expand Up @@ -169,7 +169,7 @@ export function WebMcpPanel({ registration }: { registration: RegistrationResult
<p className="truncate font-mono text-label leading-none text-ink">{tool.name}</p>
{/*
Two lines, hard. `firstSentence` is already the short form and it is still six lines
wide for `read_session_meta` in a 145px column, which turns sixteen cards into a wall
wide for `read_session_meta` in a 145px column, which turns seventeen cards into a wall
of prose nobody reads. The clamp is what makes this a scannable index; `title` on the
card keeps the sentence available to anyone who wants it.
*/}
Expand All @@ -187,7 +187,7 @@ export function WebMcpPanel({ registration }: { registration: RegistrationResult
* Whether tools are available, in one sentence, per state.
*
* `polyfill` says the quiet part out loud: the count is real and the tools work from this page, but no
* external agent can see any of them. A judge reading "16 tools" beside an amber dot deserves to know
* external agent can see any of them. A judge reading "17 tools" beside an amber dot deserves to know
* which of those two facts they are looking at.
*/
function StatusSentence({ health, count }: { health: Health; count: number }) {
Expand Down
6 changes: 3 additions & 3 deletions traces/src/lib/webmcp/register-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { registerTools, unregisterTools } from './register-tools'
* These exist because of a specific bug rather than for coverage. `registerTool` returns a promise and
* every failure the spec defines — `InvalidStateError`, `SecurityError` for an agent cluster that is not
* origin-keyed, `NotAllowedError`, a duplicate name, a bad schema — arrives as a *rejection*. The old
* implementation wrapped the call in a synchronous `try`/`catch`, which caught none of them: all sixteen
* names went into `registered` unconditionally and the banner read "WebMCP live · 16 tools" over an empty
* implementation wrapped the call in a synchronous `try`/`catch`, which caught none of them: all seventeen
* names went into `registered` unconditionally and the banner read "WebMCP live · 17 tools" over an empty
* tool list. A green banner on a dead surface is the one failure this project cannot afford, so the
* assertion below is about the *absence* of names, not the presence of them.
*
Expand Down Expand Up @@ -76,7 +76,7 @@ describe('registerTools', () => {

it('reports nothing registered when the host refuses everything', async () => {
// The failure next.config.mjs calls "the worst available failure": an origin that is not
// origin-keyed refuses every tool, and the banner used to call that sixteen live tools.
// origin-keyed refuses every tool, and the banner used to call that seventeen live tools.
vi.spyOn(console, 'warn').mockImplementation(() => {})
document.modelContext = stubModelContext({
names: allTools.map((tool) => tool.name),
Expand Down
10 changes: 5 additions & 5 deletions traces/src/lib/webmcp/register-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type RegistrationResult = {
*
* Async because `registerTool` returns a promise and every failure the spec defines arrives as a
* rejection — not a thrown exception. A synchronous `try`/`catch` here caught nothing, so a page whose
* agent cluster is not origin-keyed reported sixteen live tools while registering zero. That is the
* agent cluster is not origin-keyed reported seventeen live tools while registering zero. That is the
* failure next.config.mjs calls "the worst available failure", and this is the only place that can see
* it.
*/
Expand Down Expand Up @@ -58,13 +58,13 @@ export async function registerTools(): Promise<RegistrationResult> {
controller = surface

/*
* Concurrently, and `allSettled` rather than `all`: sixteen sequential awaits is sixteen round trips
* through the host for no reason, and one host rejecting one schema must not cost us the other
* fifteen. A surface that is fifteen-sixteenths present is worth having, and the banner shows what
* Concurrently, and `allSettled` rather than `all`: seventeen sequential awaits is seventeen round
* trips through the host for no reason, and one host rejecting one schema must not cost us the other
* sixteen. A surface that is sixteen-seventeenths present is worth having, and the banner shows what
* actually registered rather than what we hoped would.
*
* `async` on the mapper is not decoration: a host that throws synchronously instead of rejecting
* would otherwise escape `allSettled` through `map` and cost all sixteen.
* would otherwise escape `allSettled` through `map` and cost all seventeen.
*/
const outcomes = await Promise.allSettled(
allTools.map(async (tool) =>
Expand Down
6 changes: 6 additions & 0 deletions traces/src/lib/webmcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { bisectTool } from './bisect'
import { diffDomToolDefinition } from './diff-dom'
import { readConsoleTool } from './read-console'
import { readNetworkTool } from './read-network'
import { readMarkersTool } from './read-markers'
import { measureLayoutToolDefinition } from './measure-layout'
import { seekTool } from './seek'
import { annotateTool } from './annotate'
Expand Down Expand Up @@ -41,6 +42,10 @@ export const allTools: ToolDefinition[] = [
readDomAtTool,
readConsoleTool,
readNetworkTool,
// Last of the read group, not first: it is the only one that answers a question about the
// investigation rather than about the recording, and a model scanning for "read the page" must not
// land on it.
readMarkersTool,

// search
bisectTool,
Expand Down Expand Up @@ -80,6 +85,7 @@ export {
diffDomToolDefinition,
readConsoleTool,
readNetworkTool,
readMarkersTool,
measureLayoutToolDefinition,
seekTool,
annotateTool,
Expand Down
Loading
Loading