From 230c509faaaeb0ceab9a0a6f8df7a7cb389e4a42 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 21 May 2026 18:17:25 -0400 Subject: [PATCH] =?UTF-8?q?checkpoint=20=E2=9A=92=EF=B8=8F=20Generated=20w?= =?UTF-8?q?ith=20[Fabro](https://fabro.sh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run.json | 163 +++++++++++++++++- stages/001-start@1/status.json | 6 + stages/002-toolchain@1/script_invocation.json | 5 + 3 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 stages/001-start@1/status.json create mode 100644 stages/002-toolchain@1/script_invocation.json diff --git a/run.json b/run.json index 7a2d91c46..5c49b1830 100644 --- a/run.json +++ b/run.json @@ -509,14 +509,106 @@ } }, "web_url": "http://127.0.0.1:32276/runs/01KS69T5X6B5RQ87DGT5BWS1JH", - "start": null, - "status": { - "kind": "starting" + "start": { + "start_time": "2026-05-21T22:17:21.647688Z", + "run_branch": "fabro/run/01KS69T5X6B5RQ87DGT5BWS1JH", + "base_sha": "06ee2fea39a9e367134990d7ca88d2b6cf9f73ed" }, - "status_updated_at": "2026-05-21T22:17:04.223070Z", - "last_event_at": "2026-05-21T22:17:21.097412Z", + "status": { + "kind": "running" + }, + "status_updated_at": "2026-05-21T22:17:21.647748Z", + "last_event_at": "2026-05-21T22:17:24.018140Z", "pending_control": null, - "checkpoints": [], + "checkpoints": [ + { + "seq": 19, + "checkpoint": { + "timestamp": "2026-05-21T22:17:24.015813Z", + "current_node": "start", + "completed_nodes": [ + "start" + ], + "node_retries": {}, + "context_values": { + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "graph.goal": "# Stage-Based Pairing API And MCP Tool Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Simplify run pairing into a stage-based public API and expose it through a new `fabro_run_pair` MCP tool without leaking agent session IDs.\n\n**Architecture:** Public pairing targets workflow stages, identified by `StageId` (`node_id@visit`). The live agent session ID remains an internal runtime binding used by `SteeringHub` active-pair bookkeeping and live run projection, but it is not present in HTTP API schemas, public pair event bodies, generated clients, MCP tool parameters, or MCP tool results. The MCP server calls the same stage-based HTTP/client API and can return shared pair structs directly.\n\n**Tech Stack:** Rust, Axum, OpenAPI/progenitor, `fabro-types`, `fabro-api`, `fabro-client`, `fabro-server`, `fabro-workflow`, `fabro-interview`, `fabro-mcp-server`, `apps/fabro-web`, `cargo nextest`, Bun OpenAPI client generation.\n\n---\n\n## Contract\n\nPublic pair API shape:\n\n```rust\npub struct PairTarget {\n pub stage_id: StageId,\n pub node_label: String,\n}\n\npub struct PairStartRequest {\n pub stage_id: StageId,\n}\n```\n\n`GET /api/v1/runs/{id}/pair` returns:\n\n```json\n{\n \"run_id\": \"run_...\",\n \"current_pair\": null,\n \"targets\": [\n {\n \"stage_id\": \"implement@1\",\n \"node_label\": \"Implement\"\n }\n ]\n}\n```\n\n`POST /api/v1/runs/{id}/pair` accepts:\n\n```json\n{\n \"stage_id\": \"implement@1\"\n}\n```\n\nPublic API responses must not contain:\n\n- `agent_session_id`\n- `session_id`\n- `node_id`\n- `visit`\n- `provider`\n- `model`\n\nPublic pair event bodies, generated client DTOs, and MCP pair tool params/results must follow the same rule. `StageId` already contains `node_id` and `visit`. `node_label` remains because it is display data and is not derivable from `StageId`.\n\n## File Map\n\n- `docs/internal/events-strategy.md` and `docs/internal/testing-strategy.md`: read before implementation because this changes event metadata and tests.\n- `lib/crates/fabro-types/src/pair.rs`: public pair DTO definitions.\n- `docs/public/api-reference/fabro-api.yaml`: OpenAPI source of truth for HTTP pair API.\n- `lib/crates/fabro-api/build.rs`: replacement mappings for shared pair types.\n- `lib/crates/fabro-api/tests/pair_round_trip.rs`: JSON/type parity for pair DTOs.\n- `lib/crates/fabro-api/tests/run_event_round_trip.rs`: event JSON expectations after pair target changes.\n- `lib/crates/fabro-workflow/src/event/events.rs`: workflow event enum with stage-only public pair lifecycle data.\n- `lib/crates/fabro-workflow/src/event/convert.rs`: convert workflow events into public run event bodies without pair session data.\n- `lib/crates/fabro-workflow/src/steering_hub.rs`: internal pair lifecycle and live session binding.\n- `lib/crates/fabro-interview/src/control_protocol.rs`: worker control protocol fixtures and pair start payload.\n- `lib/crates/fabro-cli/src/commands/run/runner.rs`: worker control dispatch into `SteeringHub`.\n- `lib/crates/fabro-server/src/server.rs`: live run projection and pair transport.\n- `lib/crates/fabro-server/src/server/handler/pair.rs`: HTTP handlers and transcript reconstruction.\n- `lib/crates/fabro-server/src/server/tests.rs`: pair transport and live projection tests.\n- `lib/crates/fabro-client/src/client.rs`: client convenience methods.\n- `lib/crates/fabro-mcp-server/src/run_tools/pair.rs`: new MCP pair tool implementation.\n- `lib/crates/fabro-mcp-server/src/run_tools.rs`: export MCP pair tool types/functions.\n- `lib/crates/fabro-mcp-server/src/server.rs`: register `fabro_run_pair`.\n- `lib/packages/fabro-api-client`: regenerate TypeScript client after OpenAPI changes.\n- `apps/fabro-web`: update any generated-client pair consumers and run frontend checks.\n\n## Task 1: Read Strategy Docs\n\n**Files:**\n- Read: `docs/internal/events-strategy.md`\n- Read: `docs/internal/testing-strategy.md`\n\n- [ ] **Step 1: Read event strategy**\n\nRun:\n\n```bash\nsed -n '1,220p' docs/internal/events-strategy.md\n```\n\nExpected: notes on when to add or modify event variants, stored fields, and progress JSONL behavior.\n\n- [ ] **Step 2: Read testing strategy**\n\nRun:\n\n```bash\nsed -n '1,220p' docs/internal/testing-strategy.md\n```\n\nExpected: guidance for unit vs integration tests, snapshots, and fixture placement.\n\n## Task 2: Simplify Public Pair DTOs\n\n**Files:**\n- Modify: `lib/crates/fabro-types/src/pair.rs`\n- Modify: `lib/crates/fabro-api/tests/pair_round_trip.rs`\n\n- [ ] **Step 1: Write failing pair DTO tests**\n\nUpdate `lib/crates/fabro-api/tests/pair_round_trip.rs` so the pair target JSON fixture only contains `stage_id` and `node_label`.\n\nUse this fixture shape in the existing pair type parity test:\n\n```rust\nlet target_json = json!({\n \"stage_id\": \"code@1\",\n \"node_label\": \"Code\"\n});\n```\n\nAssert that the public target does not serialize removed fields:\n\n```rust\nlet target = PairTarget {\n stage_id: \"code@1\".parse().unwrap(),\n node_label: \"Code\".to_string(),\n};\nlet serialized = serde_json::to_value(&target).unwrap();\nassert_eq!(\n serialized,\n json!({\n \"stage_id\": \"code@1\",\n \"node_label\": \"Code\"\n })\n);\nassert!(serialized.get(\"agent_session_id\").is_none());\nassert!(serialized.get(\"session_id\").is_none());\nassert!(serialized.get(\"node_id\").is_none());\nassert!(serialized.get(\"visit\").is_none());\nassert!(serialized.get(\"provider\").is_none());\nassert!(serialized.get(\"model\").is_none());\n```\n\nRun:\n\n```bash\ncargo nextest run -p fabro-api pair_round_trip\n```\n\nExpected: failure because `PairTarget` still has the old fields and `PairStartRequest` still expects a selector.\n\n- [ ] **Step 2: Update public types**\n\nChange `lib/crates/fabro-types/src/pair.rs` to this public shape:\n\n```rust\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub struct PairTarget {\n pub stage_id: StageId,\n pub node_label: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub struct PairStartRequest {\n pub stage_id: StageId,\n}\n```\n\nRemove `PairTargetSelector` from the public exports if no internal caller needs it after later tasks. If a temporary compile bridge is needed during this task, leave it private to the module until the final cleanup task.\n\nChange `PairMessageRecord` from selector-based target data to stage-based data:\n\n```rust\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct PairMessageRecord {\n pub message_id: PairMessageId,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub client_message_id: Option,\n pub pair_id: PairId,\n pub run_id: RunId,\n pub stage_id: StageId,\n pub text: String,\n pub accepted_at: DateTime,\n}\n```\n\nKeep transcript entries using `PairTarget` so transcript rows remain self-describing:\n\n```rust\npub target: PairTarget\n```\n\nRemove model/provider data from pair transcript assistant messages:\n\n```rust\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct PairTranscriptAssistantMessage {\n pub seq: u32,\n pub event_id: String,\n pub ts: DateTime,\n pub pair_id: PairId,\n pub target: PairTarget,\n pub text: String,\n pub tool_call_count: usize,\n}\n```\n\nDelete `PairTranscriptModel` if no remaining public type uses it.\n\n- [ ] **Step 3: Run pair type test**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-api pair_round_trip\n```\n\nExpected: compile failures in API/OpenAPI replacement code and callers that still reference removed fields. Those are addressed in the next tasks.\n\n## Task 3: Update OpenAPI And Generated API Replacements\n\n**Files:**\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Modify: `lib/crates/fabro-api/build.rs`\n- Modify: `lib/crates/fabro-api/tests/pair_round_trip.rs`\n- Modify: `lib/crates/fabro-api/tests/run_event_round_trip.rs`\n\n- [ ] **Step 1: Update OpenAPI schemas**\n\nIn `docs/public/api-reference/fabro-api.yaml`, change the schemas near the existing pair definitions to this shape:\n\n```yaml\n PairTarget:\n type: object\n additionalProperties: false\n required:\n - stage_id\n - node_label\n properties:\n stage_id:\n $ref: \"#/components/schemas/StageId\"\n node_label:\n type: string\n\n PairStartRequest:\n type: object\n additionalProperties: false\n required:\n - stage_id\n properties:\n stage_id:\n $ref: \"#/components/schemas/StageId\"\n```\n\nRemove the `PairTargetSelector` schema and every `$ref` to it.\n\nChange `PairMessageRecord` in OpenAPI so it uses `stage_id`:\n\n```yaml\n stage_id:\n $ref: \"#/components/schemas/StageId\"\n```\n\nand remove the old `target` selector field from that record.\n\nChange `PairTranscriptAssistantMessage` so it does not expose model/provider information:\n\n```yaml\n PairTranscriptAssistantMessage:\n type: object\n additionalProperties: false\n required:\n - kind\n - seq\n - event_id\n - ts\n - pair_id\n - target\n - text\n - tool_call_count\n properties:\n kind:\n type: string\n enum: [assistant_message]\n seq:\n type: integer\n format: uint32\n event_id:\n type: string\n ts:\n type: string\n format: date-time\n pair_id:\n $ref: \"#/components/schemas/PairId\"\n target:\n $ref: \"#/components/schemas/PairTarget\"\n text:\n type: string\n tool_call_count:\n type: integer\n minimum: 0\n```\n\nRemove the `PairTranscriptModel` schema and any `$ref` to it.\n\n- [ ] **Step 2: Update `fabro-api` replacements**\n\nIn `lib/crates/fabro-api/build.rs`, remove the replacement for `PairTargetSelector`:\n\n```rust\n(\"PairTargetSelector\", \"fabro_types::PairTargetSelector\", &[]),\n```\n\nKeep the replacements for the shared public types that still exist:\n\n```rust\n(\"PairTarget\", \"fabro_types::PairTarget\", &[]),\n(\"PairRecord\", \"fabro_types::PairRecord\", &[]),\n(\"PairStartRequest\", \"fabro_types::PairStartRequest\", &[]),\n(\"PairMessageRequest\", \"fabro_types::PairMessageRequest\", &[]),\n(\"PairMessageRecord\", \"fabro_types::PairMessageRecord\", &[]),\n(\"PairTranscriptResponse\", \"fabro_types::PairTranscriptResponse\", &[]),\n```\n\n- [ ] **Step 3: Update event round-trip fixtures**\n\nIn `lib/crates/fabro-api/tests/run_event_round_trip.rs`, update `run.pair.started` expected JSON so the `target` object contains only:\n\n```json\n{\n \"stage_id\": \"code@1\",\n \"node_label\": \"Code\"\n}\n```\n\nAdd an explicit negative assertion on the serialized public event body:\n\n```rust\nlet serialized = serde_json::to_value(&event).unwrap();\nlet body = &serialized[\"body\"];\nassert!(body.to_string().contains(\"stage_id\"));\nassert!(!body.to_string().contains(\"agent_session_id\"));\nassert!(!body.to_string().contains(\"session_id\"));\nassert!(!body.to_string().contains(\"provider\"));\nassert!(!body.to_string().contains(\"model\"));\nassert!(!body.to_string().contains(\"\\\"node_id\\\"\"));\nassert!(!body.to_string().contains(\"\\\"visit\\\"\"));\n```\n\nThis assertion is scoped to the public pair event body. General agent events may still expose `session_id` through the existing events API.\n\n- [ ] **Step 4: Regenerate/build Rust API**\n\nRun:\n\n```bash\ncargo build -p fabro-api\n```\n\nExpected: generated API compiles or reports remaining references to the old `PairTargetSelector`/target fields.\n\n- [ ] **Step 5: Run API tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-api pair_round_trip run_event_round_trip\n```\n\nExpected: pass after all pair OpenAPI fixtures and replacements match the simplified public structs.\n\n## Task 4: Keep Pair Events Stage-Only\n\n**Files:**\n- Modify: `lib/crates/fabro-workflow/src/event/events.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/convert.rs`\n\n- [ ] **Step 1: Keep `RunPairStarted` free of session identifiers**\n\nIn `lib/crates/fabro-workflow/src/event/events.rs`, keep `Event::RunPairStarted` stage-only:\n\n```rust\nRunPairStarted {\n pair_id: PairId,\n target: PairTarget,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n actor: Option,\n},\n```\n\nDo not add `session_id` or `agent_session_id` to this event. The active session binding stays in `SteeringHub::ActivePair` while the run is live.\n\n- [ ] **Step 2: Keep public run event body session-free**\n\nIn `lib/crates/fabro-workflow/src/event/convert.rs`, keep conversion to `fabro_types::RunPairStartedProps` limited to:\n\n```rust\nEvent::RunPairStarted {\n pair_id, target, ..\n} => EventBody::RunPairStarted(fabro_types::RunPairStartedProps {\n pair_id: *pair_id,\n target: target.clone(),\n}),\n```\n\n- [ ] **Step 3: Add event leak assertions**\n\nIn the workflow event conversion tests, serialize a `RunPairStarted` event and assert the public body contains only stage/display pair target data:\n\n```rust\nlet value = serde_json::to_value(converted_event).unwrap();\nlet body = &value[\"body\"];\nassert!(body.to_string().contains(\"\\\"stage_id\\\"\"));\nassert!(body.to_string().contains(\"\\\"node_label\\\"\"));\nassert!(!body.to_string().contains(\"agent_session_id\"));\nassert!(!body.to_string().contains(\"session_id\"));\nassert!(!body.to_string().contains(\"\\\"node_id\\\"\"));\nassert!(!body.to_string().contains(\"\\\"visit\\\"\"));\nassert!(!body.to_string().contains(\"provider\"));\nassert!(!body.to_string().contains(\"model\"));\n```\n\n- [ ] **Step 4: Update `RunPairStarted` tracing/log output**\n\nIn `lib/crates/fabro-workflow/src/event/events.rs`, update the `Event::log` / tracing arm for `RunPairStarted`. Replace any logging field that reads from `target.agent_session_id` with stage-only context:\n\n```rust\nSelf::RunPairStarted {\n pair_id, target, ..\n} => {\n info!(\n %pair_id,\n stage_id = %target.stage_id,\n node_label = %target.node_label,\n \"Run pairing started\",\n );\n}\n```\n\nDo not log `session_id`, `agent_session_id`, provider, or model from the pair target.\n\n- [ ] **Step 5: Run workflow event tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-workflow event\n```\n\nExpected: pass after public pair events use the simplified `PairTarget` and no pair event body exposes session or agent internals.\n\n## Task 5: Refactor `SteeringHub` To Resolve Sessions Internally\n\n**Files:**\n- Modify: `lib/crates/fabro-workflow/src/steering_hub.rs`\n\n- [ ] **Step 1: Change active pair bookkeeping**\n\nChange the private active pair struct to store the resolved live session id separately from the public record:\n\n```rust\n#[derive(Debug, Clone)]\nstruct ActivePair {\n record: PairRecord,\n session_id: String,\n}\n```\n\n- [ ] **Step 2: Update `start_pair`**\n\nChange `start_pair` so it accepts a public `PairTarget` and resolves the current active session by `target.stage_id`:\n\n```rust\npub fn start_pair(\n &self,\n run_id: RunId,\n pair_id: PairId,\n target: PairTarget,\n actor: Option,\n) -> Result {\n let active = self.active.read().expect(\"active lock poisoned\");\n let Some(entry) = active.get(&target.stage_id) else {\n return Err(PairControlError::TargetNotActive);\n };\n let Some(pair_handle) = entry.pair_handle.as_ref() else {\n return Err(PairControlError::TargetNotActive);\n };\n let session_id = entry.session_id.clone();\n let interrupt_handle = Arc::clone(&entry.handle);\n let pair_handle = pair_handle.clone();\n drop(active);\n\n let mut active_pair = self.active_pair.lock().expect(\"active pair lock poisoned\");\n if active_pair.is_some() {\n return Err(PairControlError::AlreadyPaired);\n }\n\n let text = human_joined_text();\n if !pair_handle.try_enqueue_bounded(\n SteeringItem::System { text: text.to_string() },\n PER_SESSION_QUEUE_CAP,\n ) {\n return Err(PairControlError::MessageNotAccepted);\n }\n\n let record = PairRecord {\n pair_id,\n run_id,\n status: PairStatus::Active,\n started_at: Utc::now(),\n ended_at: None,\n failure_reason: None,\n target,\n };\n\n self.emitter.emit(&Event::RunPairStarted {\n pair_id,\n target: record.target.clone(),\n actor: actor.clone(),\n });\n\n interrupt_handle.interrupt(actor);\n self.emitter.emit(&Event::AgentPairSystemMessage {\n node_id: record.target.stage_id.node_id().to_string(),\n visit: record.target.stage_id.visit(),\n session_id: session_id.clone(),\n pair_id,\n kind: PairSystemMessageKind::HumanJoined,\n text: text.to_string(),\n });\n\n *active_pair = Some(ActivePair {\n record: record.clone(),\n session_id,\n });\n Ok(record)\n}\n```\n\nAdjust imports if `Arc` is not already in scope.\n\n- [ ] **Step 3: Update `send_pair_message`**\n\nUse `pair.session_id` and `pair.record.target.stage_id` instead of `pair.record.target.agent_session_id`:\n\n```rust\nlet target = &pair.record.target;\nlet session_id = pair.session_id.clone();\nlet active = self.active.read().expect(\"active lock poisoned\");\nlet Some(entry) = active.get(&target.stage_id) else {\n return Err(PairControlError::TargetNotActive);\n};\nif entry.session_id != session_id {\n return Err(PairControlError::TargetNotActive);\n}\nlet Some(pair_handle) = entry.pair_handle.as_ref() else {\n return Err(PairControlError::TargetNotActive);\n};\n```\n\nEmit `AgentPairUserMessage` with derived node/visit:\n\n```rust\nself.emitter.emit(&Event::AgentPairUserMessage {\n node_id: target.stage_id.node_id().to_string(),\n visit: target.stage_id.visit(),\n session_id,\n pair_id,\n message_id,\n client_message_id: client_message_id.clone(),\n text: text.clone(),\n actor,\n});\n```\n\nReturn stage-based message record:\n\n```rust\nOk(PairMessageRecord {\n message_id,\n client_message_id,\n pair_id,\n run_id: pair.record.run_id,\n stage_id: target.stage_id.clone(),\n text,\n accepted_at: Utc::now(),\n})\n```\n\n- [ ] **Step 4: Update `end_pair`**\n\nUse `pair.session_id` when checking the current active entry and emitting `AgentPairSystemMessage`.\n\nThe `AgentPairSystemMessage` node data should derive from `target.stage_id`:\n\n```rust\nnode_id: target.stage_id.node_id().to_string(),\nvisit: target.stage_id.visit(),\nsession_id: session_id.clone(),\n```\n\n- [ ] **Step 5: Update session-ended cleanup**\n\nChange `pair_is_active_for` and `end_active_pair_for_target` so they compare:\n\n```rust\npair.record.target.stage_id == *stage_id && pair.session_id == session_id\n```\n\nDo not compare against public target fields that no longer exist.\n\n- [ ] **Step 6: Update steering hub tests**\n\nIn the `#[cfg(test)]` helpers at the bottom of `steering_hub.rs`, change `pair_target` to construct:\n\n```rust\nPairTarget {\n stage_id: stage_id.clone(),\n node_label: stage_id.node_id().to_string(),\n}\n```\n\nUpdate assertions that previously expected `target.agent_session_id`.\n\n- [ ] **Step 7: Run steering hub tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-workflow steering_hub\n```\n\nExpected: pass after all public target field references are removed.\n\n## Task 6: Update Worker Control Protocol\n\n**Files:**\n- Modify: `lib/crates/fabro-interview/src/control_protocol.rs`\n- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs`\n\n- [ ] **Step 1: Keep `PairStart` target public**\n\n`WorkerControlMessage::PairStart` can continue to carry `PairTarget`, but that `PairTarget` is now public stage data:\n\n```rust\n#[serde(rename = \"pair.start\")]\nPairStart {\n run_id: RunId,\n pair_id: PairId,\n target: PairTarget,\n actor: Principal,\n},\n```\n\nThe worker does not receive `agent_session_id`; `SteeringHub::start_pair` resolves the active session for `target.stage_id`.\n\n- [ ] **Step 2: Update control protocol tests**\n\nIn `control_protocol.rs`, update the pair start fixture to:\n\n```rust\nPairTarget {\n stage_id: \"code@1\".parse().unwrap(),\n node_label: \"Code\".to_string(),\n}\n```\n\nAssert the serialized pair target does not contain public pair leaks. Scope this assertion to the `target` object, not the whole worker-control envelope, because envelopes/actors may legitimately grow unrelated metadata later:\n\n```rust\nlet json = serde_json::to_string(&envelope).unwrap();\nlet value: serde_json::Value = serde_json::from_str(&json).unwrap();\nlet target = &value[\"target\"];\nlet target_text = target.to_string();\nassert!(target_text.contains(\"stage_id\"));\nassert!(target_text.contains(\"node_label\"));\nassert!(!target_text.contains(\"agent_session_id\"));\nassert!(!target_text.contains(\"session_id\"));\nassert!(!target_text.contains(\"\\\"node_id\\\"\"));\nassert!(!target_text.contains(\"\\\"visit\\\"\"));\nassert!(!target_text.contains(\"provider\"));\nassert!(!target_text.contains(\"model\"));\n```\n\n- [ ] **Step 3: Confirm runner dispatch still delegates to hub**\n\nIn `lib/crates/fabro-cli/src/commands/run/runner.rs`, keep the `PairStart` arm as:\n\n```rust\nWorkerControlMessage::PairStart {\n run_id,\n pair_id,\n target,\n actor,\n} => {\n let _ = steering_hub.start_pair(run_id, pair_id, target, Some(actor));\n}\n```\n\n- [ ] **Step 4: Run interview protocol tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-interview control_protocol\n```\n\nExpected: pass with the simplified pair start JSON.\n\n## Task 7: Update Server Live Projection And Pair Handlers\n\n**Files:**\n- Modify: `lib/crates/fabro-server/src/server.rs`\n- Modify: `lib/crates/fabro-server/src/server/handler/pair.rs`\n- Modify: `lib/crates/fabro-server/src/server/tests.rs`\n\n- [ ] **Step 1: Update active API target projection**\n\nIn `lib/crates/fabro-server/src/server.rs`, when handling `AgentSessionActivated`, store public pair targets:\n\n```rust\nmanaged_run\n .active_api_targets\n .insert(stage_id.clone(), PairTarget {\n stage_id: stage_id.clone(),\n node_label: event\n .node_label\n .clone()\n .unwrap_or_else(|| stage_id.node_id().to_string()),\n });\n```\n\nKeep `active_steerable_stages: HashMap` as the session lease guard.\n\n- [ ] **Step 2: Update deactivation cleanup**\n\nIn the `AgentSessionDeactivated` branch, remove `active_api_targets` only when `active_steerable_stages` still points at the deactivating session:\n\n```rust\nif managed_run\n .active_steerable_stages\n .get(stage_id)\n .is_some_and(|current| current == session_id)\n{\n managed_run.active_steerable_stages.remove(stage_id);\n managed_run.active_api_targets.remove(stage_id);\n}\n```\n\nThis preserves the stale-deactivation protection previously provided by `target.agent_session_id`.\n\n- [ ] **Step 3: Change pair target lookup to stage id**\n\nIn `lib/crates/fabro-server/src/server/handler/pair.rs`, change `pair_target_and_transport` to accept `&StageId`:\n\n```rust\nfn pair_target_and_transport(\n state: &AppState,\n id: &RunId,\n stage_id: &StageId,\n) -> Result<(PairTarget, Option), Response> {\n let runs = state.runs.lock().expect(\"runs lock poisoned\");\n let Some(run) = runs.get(id) else {\n return Err(ApiError::not_found(\"Run not found.\").into_response());\n };\n reject_unpairable_status(run.status)?;\n let Some(target) = run.active_api_targets.get(stage_id) else {\n return Err(pair_conflict(\n \"Requested pair target is not active.\",\n \"pair_target_not_active\",\n ));\n };\n Ok((target.clone(), run.answer_transport.clone()))\n}\n```\n\n- [ ] **Step 4: Change start pair handler request**\n\nIn the `start_pair` HTTP handler, replace:\n\n```rust\nJson(req): Json,\n```\n\nusage of `req.target` with:\n\n```rust\nlet (target, transport) = match pair_target_and_transport(state.as_ref(), &id, &req.stage_id) {\n Ok(result) => result,\n Err(response) => return response,\n};\n```\n\nThe response remains `PairRecord`.\n\n- [ ] **Step 5: Keep reconstructed pair windows stage-only**\n\nKeep the private pair window struct free of session identifiers:\n\n```rust\nstruct PairWindow {\n record: PairRecord,\n start_seq: u32,\n end_seq: Option,\n}\n```\n\nIn `reconstruct_pair_windows`, reconstruct from `RunPairStarted`, `RunPairEnded`, and `RunPairFailed` only. Do not read or store `event.session_id` for pair lifecycle windows.\n\n- [ ] **Step 6: Update transcript matching**\n\nChange transcript matching to use the pair window sequence range plus `stage_id`. Do not add old-history fallback behavior for previous `agent_session_id`-based pair records; this is a greenfield API surface and fixtures should be updated to the new model.\n\nUse this logic for assistant/tool/error/warning events:\n\n```rust\nfn event_matches_pair_target(pair: &PairRecord, event: &fabro_types::RunEvent) -> bool {\n event.stage_id.as_ref() == Some(&pair.target.stage_id)\n}\n```\n\n`transcript_page` already restricts scanned events to `window.start_seq..=window.end_seq`, so `stage_id` is enough for assistant/tool/error/warning events. Pair-specific user/system entries still match by `pair_id`.\n\nWhen constructing `PairTranscriptAssistantMessage`, do not copy `props.model` into the pair transcript response. Keep only `text` and `tool_call_count` from the assistant event.\n\n- [ ] **Step 7: Add transcript stage/window regression test**\n\nIn `lib/crates/fabro-server/src/server/handler/pair.rs` tests, add a regression test for the new stage/window transcript matching. The fixture should store events in this order:\n\n```text\nseq 1: agent.message for stage code@1 before pair start\nseq 2: run.pair.started for pair_id pair_1 target code@1\nseq 3: agent.message for stage code@1 inside pair window\nseq 4: agent.tool.started for stage code@1 inside pair window\nseq 5: agent.tool.completed for stage code@1 inside pair window\nseq 6: run.pair.ended for pair_id pair_1\nseq 7: agent.message for stage code@1 after pair end\n```\n\nCall the transcript projection for `pair_1` and assert:\n\n```rust\nlet assistant_count = response\n .data\n .iter()\n .filter(|entry| matches!(entry, PairTranscriptEntry::AssistantMessage(_)))\n .count();\nlet tool_count = response\n .data\n .iter()\n .filter(|entry| matches!(entry, PairTranscriptEntry::ToolCall(_)))\n .count();\nassert_eq!(assistant_count, 1);\nassert_eq!(tool_count, 2);\n```\n\nAlso assert the transcript text contains the inside-window assistant message and does not contain the before-start or after-end assistant messages. Do not add session-id filtering to make this pass; the intended behavior is stage plus pair window.\n\n- [ ] **Step 8: Update server tests**\n\nIn `lib/crates/fabro-server/src/server/tests.rs` and `handler/pair.rs` test modules, replace old target fixtures with:\n\n```rust\nPairTarget {\n stage_id: \"code@1\".parse().unwrap(),\n node_label: \"Code\".to_string(),\n}\n```\n\nAdd the same negative assertion on every public pair HTTP response covered by tests: status, start, get, message acknowledgement, and transcript:\n\n```rust\nassert!(!body.to_string().contains(\"agent_session_id\"));\nassert!(!body.to_string().contains(\"session_id\"));\nassert!(!body.to_string().contains(\"provider\"));\nassert!(!body.to_string().contains(\"model\"));\nassert!(!body.to_string().contains(\"\\\"node_id\\\"\"));\nassert!(!body.to_string().contains(\"\\\"visit\\\"\"));\n```\n\n- [ ] **Step 9: Run server pair tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-server pair\n```\n\nExpected: pass with stage-only pair request/response JSON.\n\n## Task 8: Update Fabro Client\n\n**Files:**\n- Modify: `lib/crates/fabro-client/src/client.rs`\n\n- [ ] **Step 1: Change start pair method**\n\nChange the client method from selector-based:\n\n```rust\npub async fn start_run_pair(\n &self,\n run_id: &RunId,\n target: PairTargetSelector,\n) -> Result\n```\n\nto stage-based:\n\n```rust\npub async fn start_run_pair(\n &self,\n run_id: &RunId,\n stage_id: StageId,\n) -> Result {\n let body = PairStartRequest { stage_id };\n let response = self\n .send_api(|client| {\n let body = body.clone();\n async move {\n client\n .start_run_pair()\n .id(run_id.to_string())\n .body(body)\n .send()\n .await\n }\n })\n .await?;\n convert_type(response.into_inner())\n}\n```\n\nAdd `StageId` to the imports from `fabro_types`.\n\n- [ ] **Step 2: Remove selector imports**\n\nRemove `PairTargetSelector` imports from `fabro-client` if no longer used.\n\n- [ ] **Step 3: Run client build**\n\nRun:\n\n```bash\ncargo build -p fabro-client\n```\n\nExpected: pass after all client callers use `StageId`.\n\n## Task 9: Add `fabro_run_pair` MCP Tool\n\n**Files:**\n- Create: `lib/crates/fabro-mcp-server/src/run_tools/pair.rs`\n- Modify: `lib/crates/fabro-mcp-server/src/run_tools.rs`\n- Modify: `lib/crates/fabro-mcp-server/src/server.rs`\n\n- [ ] **Step 1: Create pair tool action and params**\n\nCreate `lib/crates/fabro-mcp-server/src/run_tools/pair.rs` with:\n\n```rust\nuse std::sync::Arc;\n\nuse fabro_client::Client;\nuse fabro_types::{PairId, PairMessageRequest, StageId};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Value, json};\n\nuse super::common::{ToolError, ToolResult};\n\nconst MAX_PAIR_MESSAGE_BYTES: usize = 8192;\n\n#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]\n#[serde(rename_all = \"snake_case\")]\npub(crate) enum RunPairAction {\n Status,\n Start,\n Get,\n Message,\n End,\n Transcript,\n}\n\n#[derive(Debug, Deserialize, JsonSchema)]\npub(crate) struct FabroRunPairParams {\n pub(crate) action: RunPairAction,\n pub(crate) run_id: Option,\n pub(crate) pair_id: Option,\n pub(crate) stage_id: Option,\n pub(crate) text: Option,\n pub(crate) client_message_id: Option,\n pub(crate) since_seq: Option,\n pub(crate) limit: Option,\n}\n```\n\n`run_id` is intentionally `Option` so missing input reaches `TryFrom` and returns the tool-level `run_id is required` error instead of a serde deserialization error. The validator still requires it for every action.\n\n- [ ] **Step 2: Add validated action types**\n\nAdd:\n\n```rust\n#[derive(Debug)]\npub(crate) struct ValidatedPairRun {\n pub(crate) run_id: String,\n pub(crate) action: ValidatedPairAction,\n}\n\n#[derive(Debug)]\npub(crate) enum ValidatedPairAction {\n Status,\n Start { stage_id: StageId },\n Get { pair_id: PairId },\n Message {\n pair_id: PairId,\n text: String,\n client_message_id: Option,\n },\n End { pair_id: PairId },\n Transcript {\n pair_id: PairId,\n since_seq: Option,\n limit: Option,\n },\n}\n```\n\nImplement `TryFrom for ValidatedPairRun` with these exact validation errors:\n\n```text\nrun_id is required\nstage_id is required for action start\npair_id is required for action get\npair_id is required for action message\npair_id is required for action end\npair_id is required for action transcript\ntext is required for action message\ntext must be at most 8192 bytes for action message\ninvalid stage_id for action start: \ninvalid pair_id for action : \n```\n\nMake `run_id` optional in `FabroRunPairParams` so missing input reaches `TryFrom` instead of failing serde deserialization before the tool can return the planned error:\n\n```rust\nlet Some(run_id) = params\n .run_id\n .as_deref()\n .map(str::trim)\n .filter(|run_id| !run_id.is_empty())\nelse {\n return Err(ToolError::message(\"run_id is required\"));\n};\nlet run_id = run_id.to_string();\n```\n\nParse `stage_id` with `raw.parse::()`. Parse `pair_id` with `raw.parse::()`.\n\n- [ ] **Step 3: Add execution function**\n\nAdd:\n\n```rust\n#[derive(Debug, Serialize, JsonSchema)]\npub(crate) struct PairRunResult {\n pub(crate) run_id: String,\n pub(crate) action: RunPairAction,\n pub(crate) result: Value,\n}\n\npub(crate) async fn pair_run(\n client: Arc,\n params: ValidatedPairRun,\n) -> ToolResult {\n let run_id = client\n .resolve_run(¶ms.run_id)\n .await\n .map_err(|err| ToolError::from_anyhow(&err))?\n .id;\n\n let (action, result) = match params.action {\n ValidatedPairAction::Status => (\n RunPairAction::Status,\n json!(client.get_run_pair_status(&run_id).await.map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::Start { stage_id } => (\n RunPairAction::Start,\n json!(client.start_run_pair(&run_id, stage_id).await.map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::Get { pair_id } => (\n RunPairAction::Get,\n json!(client.get_run_pair(&run_id, &pair_id).await.map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::Message {\n pair_id,\n text,\n client_message_id,\n } => (\n RunPairAction::Message,\n json!(client\n .send_run_pair_message(\n &run_id,\n &pair_id,\n PairMessageRequest {\n text,\n client_message_id,\n },\n )\n .await\n .map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::End { pair_id } => (\n RunPairAction::End,\n json!(client.end_run_pair(&run_id, &pair_id).await.map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::Transcript {\n pair_id,\n since_seq,\n limit,\n } => (\n RunPairAction::Transcript,\n json!(client\n .get_run_pair_transcript(&run_id, &pair_id, since_seq, limit)\n .await\n .map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n };\n\n Ok(PairRunResult {\n run_id: run_id.to_string(),\n action,\n result,\n })\n}\n```\n\nIf rustfmt expands these match arms, accept the formatted output.\n\n- [ ] **Step 4: Add text summary**\n\nAdd:\n\n```rust\npub(crate) fn pair_run_text(result: &PairRunResult) -> String {\n match result.action {\n RunPairAction::Status => format!(\"read pair status for Fabro run {}\", result.run_id),\n RunPairAction::Start => format!(\"started pair for Fabro run {}\", result.run_id),\n RunPairAction::Get => format!(\"read pair for Fabro run {}\", result.run_id),\n RunPairAction::Message => format!(\"sent pair message for Fabro run {}\", result.run_id),\n RunPairAction::End => format!(\"ended pair for Fabro run {}\", result.run_id),\n RunPairAction::Transcript => {\n format!(\"read pair transcript for Fabro run {}\", result.run_id)\n }\n }\n}\n```\n\n- [ ] **Step 5: Add validation tests**\n\nIn `pair.rs`, add unit tests for:\n\n```rust\n#[test]\nfn missing_or_blank_run_id_returns_tool_error() { ... }\n\n#[test]\nfn start_requires_stage_id() { ... }\n\n#[test]\nfn message_requires_pair_id_and_text() { ... }\n\n#[test]\nfn message_rejects_overlong_text() { ... }\n\n#[test]\nfn transcript_requires_pair_id() { ... }\n```\n\nUse `ValidatedPairRun::try_from(...)` and assert the exact error text contains the messages listed in Step 2.\n\n- [ ] **Step 6: Export pair tool functions**\n\nIn `lib/crates/fabro-mcp-server/src/run_tools.rs`, add:\n\n```rust\nmod pair;\n```\n\nand:\n\n```rust\npub(crate) use pair::{\n FabroRunPairParams, ValidatedPairRun, pair_run, pair_run_text,\n};\n```\n\n- [ ] **Step 7: Register MCP tool**\n\nIn `lib/crates/fabro-mcp-server/src/server.rs`, add:\n\n```rust\n#[tool(\n name = \"fabro_run_pair\",\n description = \"Inspect, start, message, end, or read transcript for a live Fabro run pairing session.\"\n)]\nasync fn fabro_run_pair(\n &self,\n params: Parameters,\n) -> Result {\n let params = match run_tools::ValidatedPairRun::try_from(params.0) {\n Ok(params) => params,\n Err(err) => return Ok(run_tools::error_result(err)),\n };\n let client = match self.client().await {\n Ok(client) => client,\n Err(err) => return Ok(run_tools::error_result(err)),\n };\n match run_tools::pair_run(client, params).await {\n Ok(result) => run_tools::success_result(&result, run_tools::pair_run_text(&result)),\n Err(err) => Ok(run_tools::error_result(err)),\n }\n}\n```\n\n- [ ] **Step 8: Add MCP registration and schema tests**\n\nIn `lib/crates/fabro-mcp-server/src/server.rs`, add a `#[cfg(test)]` module that constructs `FabroMcpServer` and inspects `tool_router.list_all()`:\n\n```rust\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n use std::sync::Arc;\n\n use serde_json::Value;\n\n use super::*;\n use crate::FabroMcpServerSettings;\n\n #[test]\n fn fabro_run_pair_tool_is_registered_with_stage_based_schema() {\n let settings = FabroMcpServerSettings {\n cwd: PathBuf::from(\".\"),\n config_path: PathBuf::from(\"fabro.toml\"),\n client_factory: Arc::new(|| {\n Box::pin(async { panic!(\"client should not be constructed while listing tools\") })\n }),\n };\n let server = FabroMcpServer::new(Arc::new(settings));\n let tools = server.tool_router.list_all();\n let tool = tools\n .iter()\n .find(|tool| tool.name.as_ref() == \"fabro_run_pair\")\n .expect(\"fabro_run_pair should be registered\");\n let schema = Value::Object(tool.input_schema.as_ref().clone());\n let schema_text = schema.to_string();\n\n assert!(schema_text.contains(\"stage_id\"));\n assert!(!schema_text.contains(\"agent_session_id\"));\n assert!(!schema_text.contains(\"session_id\"));\n assert!(!schema_text.contains(\"PairTargetSelector\"));\n assert!(!schema_text.contains(\"\\\"target\\\"\"));\n assert!(!schema_text.contains(\"provider\"));\n assert!(!schema_text.contains(\"model\"));\n assert!(!schema_text.contains(\"\\\"node_id\\\"\"));\n assert!(!schema_text.contains(\"\\\"visit\\\"\"));\n }\n}\n```\n\n- [ ] **Step 9: Add MCP result leakage tests**\n\nIn `lib/crates/fabro-mcp-server/src/run_tools/pair.rs`, add unit tests that serialize representative `PairRunResult` values for `status`, `start`, `message`, and `transcript` and assert no public result includes forbidden fields:\n\n```rust\nfn assert_no_public_pair_leaks(value: &serde_json::Value) {\n let text = value.to_string();\n assert!(!text.contains(\"agent_session_id\"));\n assert!(!text.contains(\"session_id\"));\n assert!(!text.contains(\"provider\"));\n assert!(!text.contains(\"model\"));\n assert!(!text.contains(\"\\\"node_id\\\"\"));\n assert!(!text.contains(\"\\\"visit\\\"\"));\n}\n```\n\nUse public pair fixture values that contain `PairTarget { stage_id, node_label }` only.\n\n- [ ] **Step 10: Run MCP server tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-mcp-server\n```\n\nExpected: pass after validation tests and tool registration compile.\n\n## Task 10: Regenerate TypeScript API Client\n\n**Files:**\n- Modify generated files under: `lib/packages/fabro-api-client`\n\n- [ ] **Step 1: Generate client**\n\nRun:\n\n```bash\ncd lib/packages/fabro-api-client && bun run generate\n```\n\nExpected: TypeScript client updates pair DTOs to stage-based shape.\n\n- [ ] **Step 2: Inspect generated diff**\n\nRun:\n\n```bash\ngit diff -- lib/packages/fabro-api-client | sed -n '1,240p'\n```\n\nExpected: pair schemas remove `agent_session_id`, `session_id`, `node_id`, `visit`, `provider`, and `model`; `PairStartRequest` gains `stage_id`.\n\n- [ ] **Step 3: Verify generated pair DTOs do not leak internals**\n\nRun:\n\n```bash\nrg -n \"PairTargetSelector|agent_session_id|session_id|['\\\"]provider['\\\"]|[[:space:]]provider:|['\\\"]model['\\\"]|[[:space:]]model:|['\\\"]node_id['\\\"]|[[:space:]]node_id:|['\\\"]visit['\\\"]|[[:space:]]visit:\" \\\n lib/packages/fabro-api-client/src/models/pair-*.ts \\\n lib/packages/fabro-api-client/src/models/run-pair-status-response.ts \\\n lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts\n```\n\nExpected: no matches in generated pair DTOs or human-in-the-loop pair method signatures. If `rg` reports a generated pair file that should have been deleted, remove the stale generated file through the generator output or the normal generated-client cleanup path.\n\n## Task 11: Update Frontend Consumers\n\n**Files:**\n- Search and modify as needed under: `apps/fabro-web`\n\n- [ ] **Step 1: Search frontend pair consumers**\n\nRun:\n\n```bash\nrg -n \"startRunPair|getRunPairStatus|getRunPairTranscript|sendRunPairMessage|PairStartRequest|PairTargetSelector|agent_session_id|session_id|['\\\"]provider['\\\"]|[[:space:]]provider:|['\\\"]model['\\\"]|[[:space:]]model:|['\\\"]node_id['\\\"]|[[:space:]]node_id:|['\\\"]visit['\\\"]|[[:space:]]visit:\" apps/fabro-web\n```\n\nExpected: review every match manually. Update real pair API consumers to use `stage_id` and the simplified generated DTOs. Ignore unrelated uses where the match is not part of the pair API, such as generic agent event rendering or text containing the word \"pair\".\n\n- [ ] **Step 2: Update frontend pair request construction**\n\nIf `apps/fabro-web` constructs a pair start request, change it from:\n\n```ts\nawait api.startRunPair(runId, {\n target: {\n stage_id: target.stage_id,\n agent_session_id: target.agent_session_id,\n },\n});\n```\n\nto:\n\n```ts\nawait api.startRunPair(runId, {\n stage_id: target.stage_id,\n});\n```\n\nIf there are no frontend pair API consumers, record that in the implementation notes and leave frontend source unchanged.\n\n- [ ] **Step 3: Run frontend typecheck**\n\nRun:\n\n```bash\ncd apps/fabro-web && bun run typecheck\n```\n\nExpected: pass.\n\n- [ ] **Step 4: Run frontend tests**\n\nRun:\n\n```bash\ncd apps/fabro-web && bun test\n```\n\nExpected: pass.\n\n## Task 12: Final Cleanup And Verification\n\n**Files:**\n- Search all Rust/OpenAPI/TS files for removed public fields.\n\n- [ ] **Step 1: Check public surfaces for leaked pair internals**\n\nRun:\n\n```bash\nrg -n \"PairTargetSelector|agent_session_id|session_id|['\\\"]provider['\\\"]|[[:space:]]provider:|['\\\"]model['\\\"]|[[:space:]]model:|['\\\"]node_id['\\\"]|[[:space:]]node_id:|['\\\"]visit['\\\"]|[[:space:]]visit:\" \\\n docs/public/api-reference/fabro-api.yaml \\\n lib/crates/fabro-types/src/pair.rs \\\n lib/crates/fabro-api/tests/pair_round_trip.rs \\\n lib/crates/fabro-api/tests/run_event_round_trip.rs \\\n lib/packages/fabro-api-client/src/models/pair-*.ts \\\n lib/packages/fabro-api-client/src/models/run-pair-status-response.ts \\\n lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts \\\n lib/crates/fabro-mcp-server/src/run_tools/pair.rs \\\n lib/crates/fabro-mcp-server/src/server.rs\n```\n\nExpected: no matches that expose those names through pair API schemas, pair DTOs, generated pair DTOs, or MCP pair params/results. Matches inside negative leakage assertions such as `assert!(!text.contains(\"session_id\"))` are expected and should be reviewed, not deleted.\n\n- [ ] **Step 2: Check internal runtime session usage separately**\n\nRun:\n\n```bash\nrg -n \"agent_session_id|session_id\" \\\n lib/crates/fabro-workflow/src/steering_hub.rs \\\n lib/crates/fabro-workflow/src/handler/llm \\\n lib/crates/fabro-server/src/server.rs \\\n lib/crates/fabro-server/src/server/handler/pair.rs \\\n lib/crates/fabro-interview/src/control_protocol.rs \\\n lib/crates/fabro-cli/src/commands/run/runner.rs\n```\n\nExpected: internal `session_id` usage remains where it protects live session leases, emits ordinary agent events, or routes active pair messages. `agent_session_id` should not remain unless it belongs to unrelated legacy tests that were not part of the pair API and have been consciously reviewed.\n\n- [ ] **Step 3: Run caller migration search**\n\nRun:\n\n```bash\nrg -n \"start_run_pair|PairStartRequest|PairTargetSelector|agent_session_id|\\\\.target\" \\\n lib apps docs/public/api-reference/fabro-api.yaml\n```\n\nExpected: review every match manually. Valid remaining matches include `PairRecord.target`, transcript entry `target`, public negative leakage assertions, and internal non-pair session handling. Invalid matches include selector-based `start_run_pair` calls, `PairStartRequest { target: ... }`, or public `agent_session_id` exposure.\n\n- [ ] **Step 4: Run focused test suite**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-api pair_round_trip run_event_round_trip\ncargo nextest run -p fabro-workflow steering_hub\ncargo nextest run -p fabro-interview control_protocol\ncargo nextest run -p fabro-server pair\ncargo nextest run -p fabro-mcp-server\n(cd apps/fabro-web && bun run typecheck)\n(cd apps/fabro-web && bun test)\n```\n\nExpected: all pass.\n\n- [ ] **Step 5: Run workspace build**\n\nRun:\n\n```bash\ncargo build --workspace\n```\n\nExpected: pass.\n\n- [ ] **Step 6: Run formatting check**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 fmt --check --all\n```\n\nExpected: pass. If it fails, run `cargo +nightly-2026-04-14 fmt --all` and repeat the check.\n\n- [ ] **Step 7: Run clippy**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\n```\n\nExpected: pass.\n\n- [ ] **Step 8: Run workspace tests**\n\nRun:\n\n```bash\ncargo nextest run --workspace\n```\n\nExpected: pass. If macOS reports `Too many open files`, rerun with:\n\n```bash\nulimit -n 4096 && cargo nextest run --workspace\n```\n\n## Acceptance Criteria\n\n- `POST /api/v1/runs/{id}/pair` accepts only `stage_id` for target selection.\n- `GET /api/v1/runs/{id}/pair` returns targets with only `stage_id` and `node_label`.\n- Pair records, message acknowledgements, transcript entries, public pair event bodies, generated pair DTOs, and MCP pair params/results do not expose `agent_session_id`, `session_id`, `provider`, `model`, raw `node_id`, or raw `visit`.\n- Runtime still uses session IDs internally to avoid stale session cleanup and route active pair messages, but that state does not cross the public pair API or MCP boundary.\n- `fabro_run_pair` is registered with actions `status`, `start`, `get`, `message`, `end`, and `transcript`.\n- `fabro_run_pair` input schema contains `stage_id` and does not contain selector/session fields.\n- MCP callers can start pairing with `run_id + stage_id`.\n- Generated Rust and TypeScript API clients match the simplified OpenAPI contract.\n- Frontend pair consumers, if any, use the simplified generated DTOs.\n- Focused pair tests, MCP tests, frontend typecheck/tests, workspace build, fmt, clippy, and workspace tests pass.\n", + "internal.fidelity": "compact", + "current_node": "start", + "internal.retry_count.start": 0, + "failure_signature": "", + "outcome": "succeeded", + "internal.node_visit_count": 1, + "graph.rankdir": "LR", + "internal.run_id": "01KS69T5X6B5RQ87DGT5BWS1JH", + "internal.thread_id": null, + "internal.work_dir": "/home/daytona/workspace/fabro", + "failure_class": "" + }, + "node_outcomes": { + "start": { + "status": "succeeded", + "usage": null + } + }, + "next_node_id": "toolchain", + "node_visits": { + "start": 1 + } + }, + "diff": {} + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-05-21T22:17:25.489391Z", + "current_node": "toolchain", + "completed_nodes": [ + "start", + "toolchain" + ], + "node_retries": {}, + "context_values": { + "graph.goal": "# Stage-Based Pairing API And MCP Tool Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Simplify run pairing into a stage-based public API and expose it through a new `fabro_run_pair` MCP tool without leaking agent session IDs.\n\n**Architecture:** Public pairing targets workflow stages, identified by `StageId` (`node_id@visit`). The live agent session ID remains an internal runtime binding used by `SteeringHub` active-pair bookkeeping and live run projection, but it is not present in HTTP API schemas, public pair event bodies, generated clients, MCP tool parameters, or MCP tool results. The MCP server calls the same stage-based HTTP/client API and can return shared pair structs directly.\n\n**Tech Stack:** Rust, Axum, OpenAPI/progenitor, `fabro-types`, `fabro-api`, `fabro-client`, `fabro-server`, `fabro-workflow`, `fabro-interview`, `fabro-mcp-server`, `apps/fabro-web`, `cargo nextest`, Bun OpenAPI client generation.\n\n---\n\n## Contract\n\nPublic pair API shape:\n\n```rust\npub struct PairTarget {\n pub stage_id: StageId,\n pub node_label: String,\n}\n\npub struct PairStartRequest {\n pub stage_id: StageId,\n}\n```\n\n`GET /api/v1/runs/{id}/pair` returns:\n\n```json\n{\n \"run_id\": \"run_...\",\n \"current_pair\": null,\n \"targets\": [\n {\n \"stage_id\": \"implement@1\",\n \"node_label\": \"Implement\"\n }\n ]\n}\n```\n\n`POST /api/v1/runs/{id}/pair` accepts:\n\n```json\n{\n \"stage_id\": \"implement@1\"\n}\n```\n\nPublic API responses must not contain:\n\n- `agent_session_id`\n- `session_id`\n- `node_id`\n- `visit`\n- `provider`\n- `model`\n\nPublic pair event bodies, generated client DTOs, and MCP pair tool params/results must follow the same rule. `StageId` already contains `node_id` and `visit`. `node_label` remains because it is display data and is not derivable from `StageId`.\n\n## File Map\n\n- `docs/internal/events-strategy.md` and `docs/internal/testing-strategy.md`: read before implementation because this changes event metadata and tests.\n- `lib/crates/fabro-types/src/pair.rs`: public pair DTO definitions.\n- `docs/public/api-reference/fabro-api.yaml`: OpenAPI source of truth for HTTP pair API.\n- `lib/crates/fabro-api/build.rs`: replacement mappings for shared pair types.\n- `lib/crates/fabro-api/tests/pair_round_trip.rs`: JSON/type parity for pair DTOs.\n- `lib/crates/fabro-api/tests/run_event_round_trip.rs`: event JSON expectations after pair target changes.\n- `lib/crates/fabro-workflow/src/event/events.rs`: workflow event enum with stage-only public pair lifecycle data.\n- `lib/crates/fabro-workflow/src/event/convert.rs`: convert workflow events into public run event bodies without pair session data.\n- `lib/crates/fabro-workflow/src/steering_hub.rs`: internal pair lifecycle and live session binding.\n- `lib/crates/fabro-interview/src/control_protocol.rs`: worker control protocol fixtures and pair start payload.\n- `lib/crates/fabro-cli/src/commands/run/runner.rs`: worker control dispatch into `SteeringHub`.\n- `lib/crates/fabro-server/src/server.rs`: live run projection and pair transport.\n- `lib/crates/fabro-server/src/server/handler/pair.rs`: HTTP handlers and transcript reconstruction.\n- `lib/crates/fabro-server/src/server/tests.rs`: pair transport and live projection tests.\n- `lib/crates/fabro-client/src/client.rs`: client convenience methods.\n- `lib/crates/fabro-mcp-server/src/run_tools/pair.rs`: new MCP pair tool implementation.\n- `lib/crates/fabro-mcp-server/src/run_tools.rs`: export MCP pair tool types/functions.\n- `lib/crates/fabro-mcp-server/src/server.rs`: register `fabro_run_pair`.\n- `lib/packages/fabro-api-client`: regenerate TypeScript client after OpenAPI changes.\n- `apps/fabro-web`: update any generated-client pair consumers and run frontend checks.\n\n## Task 1: Read Strategy Docs\n\n**Files:**\n- Read: `docs/internal/events-strategy.md`\n- Read: `docs/internal/testing-strategy.md`\n\n- [ ] **Step 1: Read event strategy**\n\nRun:\n\n```bash\nsed -n '1,220p' docs/internal/events-strategy.md\n```\n\nExpected: notes on when to add or modify event variants, stored fields, and progress JSONL behavior.\n\n- [ ] **Step 2: Read testing strategy**\n\nRun:\n\n```bash\nsed -n '1,220p' docs/internal/testing-strategy.md\n```\n\nExpected: guidance for unit vs integration tests, snapshots, and fixture placement.\n\n## Task 2: Simplify Public Pair DTOs\n\n**Files:**\n- Modify: `lib/crates/fabro-types/src/pair.rs`\n- Modify: `lib/crates/fabro-api/tests/pair_round_trip.rs`\n\n- [ ] **Step 1: Write failing pair DTO tests**\n\nUpdate `lib/crates/fabro-api/tests/pair_round_trip.rs` so the pair target JSON fixture only contains `stage_id` and `node_label`.\n\nUse this fixture shape in the existing pair type parity test:\n\n```rust\nlet target_json = json!({\n \"stage_id\": \"code@1\",\n \"node_label\": \"Code\"\n});\n```\n\nAssert that the public target does not serialize removed fields:\n\n```rust\nlet target = PairTarget {\n stage_id: \"code@1\".parse().unwrap(),\n node_label: \"Code\".to_string(),\n};\nlet serialized = serde_json::to_value(&target).unwrap();\nassert_eq!(\n serialized,\n json!({\n \"stage_id\": \"code@1\",\n \"node_label\": \"Code\"\n })\n);\nassert!(serialized.get(\"agent_session_id\").is_none());\nassert!(serialized.get(\"session_id\").is_none());\nassert!(serialized.get(\"node_id\").is_none());\nassert!(serialized.get(\"visit\").is_none());\nassert!(serialized.get(\"provider\").is_none());\nassert!(serialized.get(\"model\").is_none());\n```\n\nRun:\n\n```bash\ncargo nextest run -p fabro-api pair_round_trip\n```\n\nExpected: failure because `PairTarget` still has the old fields and `PairStartRequest` still expects a selector.\n\n- [ ] **Step 2: Update public types**\n\nChange `lib/crates/fabro-types/src/pair.rs` to this public shape:\n\n```rust\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub struct PairTarget {\n pub stage_id: StageId,\n pub node_label: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub struct PairStartRequest {\n pub stage_id: StageId,\n}\n```\n\nRemove `PairTargetSelector` from the public exports if no internal caller needs it after later tasks. If a temporary compile bridge is needed during this task, leave it private to the module until the final cleanup task.\n\nChange `PairMessageRecord` from selector-based target data to stage-based data:\n\n```rust\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct PairMessageRecord {\n pub message_id: PairMessageId,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub client_message_id: Option,\n pub pair_id: PairId,\n pub run_id: RunId,\n pub stage_id: StageId,\n pub text: String,\n pub accepted_at: DateTime,\n}\n```\n\nKeep transcript entries using `PairTarget` so transcript rows remain self-describing:\n\n```rust\npub target: PairTarget\n```\n\nRemove model/provider data from pair transcript assistant messages:\n\n```rust\n#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\npub struct PairTranscriptAssistantMessage {\n pub seq: u32,\n pub event_id: String,\n pub ts: DateTime,\n pub pair_id: PairId,\n pub target: PairTarget,\n pub text: String,\n pub tool_call_count: usize,\n}\n```\n\nDelete `PairTranscriptModel` if no remaining public type uses it.\n\n- [ ] **Step 3: Run pair type test**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-api pair_round_trip\n```\n\nExpected: compile failures in API/OpenAPI replacement code and callers that still reference removed fields. Those are addressed in the next tasks.\n\n## Task 3: Update OpenAPI And Generated API Replacements\n\n**Files:**\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Modify: `lib/crates/fabro-api/build.rs`\n- Modify: `lib/crates/fabro-api/tests/pair_round_trip.rs`\n- Modify: `lib/crates/fabro-api/tests/run_event_round_trip.rs`\n\n- [ ] **Step 1: Update OpenAPI schemas**\n\nIn `docs/public/api-reference/fabro-api.yaml`, change the schemas near the existing pair definitions to this shape:\n\n```yaml\n PairTarget:\n type: object\n additionalProperties: false\n required:\n - stage_id\n - node_label\n properties:\n stage_id:\n $ref: \"#/components/schemas/StageId\"\n node_label:\n type: string\n\n PairStartRequest:\n type: object\n additionalProperties: false\n required:\n - stage_id\n properties:\n stage_id:\n $ref: \"#/components/schemas/StageId\"\n```\n\nRemove the `PairTargetSelector` schema and every `$ref` to it.\n\nChange `PairMessageRecord` in OpenAPI so it uses `stage_id`:\n\n```yaml\n stage_id:\n $ref: \"#/components/schemas/StageId\"\n```\n\nand remove the old `target` selector field from that record.\n\nChange `PairTranscriptAssistantMessage` so it does not expose model/provider information:\n\n```yaml\n PairTranscriptAssistantMessage:\n type: object\n additionalProperties: false\n required:\n - kind\n - seq\n - event_id\n - ts\n - pair_id\n - target\n - text\n - tool_call_count\n properties:\n kind:\n type: string\n enum: [assistant_message]\n seq:\n type: integer\n format: uint32\n event_id:\n type: string\n ts:\n type: string\n format: date-time\n pair_id:\n $ref: \"#/components/schemas/PairId\"\n target:\n $ref: \"#/components/schemas/PairTarget\"\n text:\n type: string\n tool_call_count:\n type: integer\n minimum: 0\n```\n\nRemove the `PairTranscriptModel` schema and any `$ref` to it.\n\n- [ ] **Step 2: Update `fabro-api` replacements**\n\nIn `lib/crates/fabro-api/build.rs`, remove the replacement for `PairTargetSelector`:\n\n```rust\n(\"PairTargetSelector\", \"fabro_types::PairTargetSelector\", &[]),\n```\n\nKeep the replacements for the shared public types that still exist:\n\n```rust\n(\"PairTarget\", \"fabro_types::PairTarget\", &[]),\n(\"PairRecord\", \"fabro_types::PairRecord\", &[]),\n(\"PairStartRequest\", \"fabro_types::PairStartRequest\", &[]),\n(\"PairMessageRequest\", \"fabro_types::PairMessageRequest\", &[]),\n(\"PairMessageRecord\", \"fabro_types::PairMessageRecord\", &[]),\n(\"PairTranscriptResponse\", \"fabro_types::PairTranscriptResponse\", &[]),\n```\n\n- [ ] **Step 3: Update event round-trip fixtures**\n\nIn `lib/crates/fabro-api/tests/run_event_round_trip.rs`, update `run.pair.started` expected JSON so the `target` object contains only:\n\n```json\n{\n \"stage_id\": \"code@1\",\n \"node_label\": \"Code\"\n}\n```\n\nAdd an explicit negative assertion on the serialized public event body:\n\n```rust\nlet serialized = serde_json::to_value(&event).unwrap();\nlet body = &serialized[\"body\"];\nassert!(body.to_string().contains(\"stage_id\"));\nassert!(!body.to_string().contains(\"agent_session_id\"));\nassert!(!body.to_string().contains(\"session_id\"));\nassert!(!body.to_string().contains(\"provider\"));\nassert!(!body.to_string().contains(\"model\"));\nassert!(!body.to_string().contains(\"\\\"node_id\\\"\"));\nassert!(!body.to_string().contains(\"\\\"visit\\\"\"));\n```\n\nThis assertion is scoped to the public pair event body. General agent events may still expose `session_id` through the existing events API.\n\n- [ ] **Step 4: Regenerate/build Rust API**\n\nRun:\n\n```bash\ncargo build -p fabro-api\n```\n\nExpected: generated API compiles or reports remaining references to the old `PairTargetSelector`/target fields.\n\n- [ ] **Step 5: Run API tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-api pair_round_trip run_event_round_trip\n```\n\nExpected: pass after all pair OpenAPI fixtures and replacements match the simplified public structs.\n\n## Task 4: Keep Pair Events Stage-Only\n\n**Files:**\n- Modify: `lib/crates/fabro-workflow/src/event/events.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/convert.rs`\n\n- [ ] **Step 1: Keep `RunPairStarted` free of session identifiers**\n\nIn `lib/crates/fabro-workflow/src/event/events.rs`, keep `Event::RunPairStarted` stage-only:\n\n```rust\nRunPairStarted {\n pair_id: PairId,\n target: PairTarget,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n actor: Option,\n},\n```\n\nDo not add `session_id` or `agent_session_id` to this event. The active session binding stays in `SteeringHub::ActivePair` while the run is live.\n\n- [ ] **Step 2: Keep public run event body session-free**\n\nIn `lib/crates/fabro-workflow/src/event/convert.rs`, keep conversion to `fabro_types::RunPairStartedProps` limited to:\n\n```rust\nEvent::RunPairStarted {\n pair_id, target, ..\n} => EventBody::RunPairStarted(fabro_types::RunPairStartedProps {\n pair_id: *pair_id,\n target: target.clone(),\n}),\n```\n\n- [ ] **Step 3: Add event leak assertions**\n\nIn the workflow event conversion tests, serialize a `RunPairStarted` event and assert the public body contains only stage/display pair target data:\n\n```rust\nlet value = serde_json::to_value(converted_event).unwrap();\nlet body = &value[\"body\"];\nassert!(body.to_string().contains(\"\\\"stage_id\\\"\"));\nassert!(body.to_string().contains(\"\\\"node_label\\\"\"));\nassert!(!body.to_string().contains(\"agent_session_id\"));\nassert!(!body.to_string().contains(\"session_id\"));\nassert!(!body.to_string().contains(\"\\\"node_id\\\"\"));\nassert!(!body.to_string().contains(\"\\\"visit\\\"\"));\nassert!(!body.to_string().contains(\"provider\"));\nassert!(!body.to_string().contains(\"model\"));\n```\n\n- [ ] **Step 4: Update `RunPairStarted` tracing/log output**\n\nIn `lib/crates/fabro-workflow/src/event/events.rs`, update the `Event::log` / tracing arm for `RunPairStarted`. Replace any logging field that reads from `target.agent_session_id` with stage-only context:\n\n```rust\nSelf::RunPairStarted {\n pair_id, target, ..\n} => {\n info!(\n %pair_id,\n stage_id = %target.stage_id,\n node_label = %target.node_label,\n \"Run pairing started\",\n );\n}\n```\n\nDo not log `session_id`, `agent_session_id`, provider, or model from the pair target.\n\n- [ ] **Step 5: Run workflow event tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-workflow event\n```\n\nExpected: pass after public pair events use the simplified `PairTarget` and no pair event body exposes session or agent internals.\n\n## Task 5: Refactor `SteeringHub` To Resolve Sessions Internally\n\n**Files:**\n- Modify: `lib/crates/fabro-workflow/src/steering_hub.rs`\n\n- [ ] **Step 1: Change active pair bookkeeping**\n\nChange the private active pair struct to store the resolved live session id separately from the public record:\n\n```rust\n#[derive(Debug, Clone)]\nstruct ActivePair {\n record: PairRecord,\n session_id: String,\n}\n```\n\n- [ ] **Step 2: Update `start_pair`**\n\nChange `start_pair` so it accepts a public `PairTarget` and resolves the current active session by `target.stage_id`:\n\n```rust\npub fn start_pair(\n &self,\n run_id: RunId,\n pair_id: PairId,\n target: PairTarget,\n actor: Option,\n) -> Result {\n let active = self.active.read().expect(\"active lock poisoned\");\n let Some(entry) = active.get(&target.stage_id) else {\n return Err(PairControlError::TargetNotActive);\n };\n let Some(pair_handle) = entry.pair_handle.as_ref() else {\n return Err(PairControlError::TargetNotActive);\n };\n let session_id = entry.session_id.clone();\n let interrupt_handle = Arc::clone(&entry.handle);\n let pair_handle = pair_handle.clone();\n drop(active);\n\n let mut active_pair = self.active_pair.lock().expect(\"active pair lock poisoned\");\n if active_pair.is_some() {\n return Err(PairControlError::AlreadyPaired);\n }\n\n let text = human_joined_text();\n if !pair_handle.try_enqueue_bounded(\n SteeringItem::System { text: text.to_string() },\n PER_SESSION_QUEUE_CAP,\n ) {\n return Err(PairControlError::MessageNotAccepted);\n }\n\n let record = PairRecord {\n pair_id,\n run_id,\n status: PairStatus::Active,\n started_at: Utc::now(),\n ended_at: None,\n failure_reason: None,\n target,\n };\n\n self.emitter.emit(&Event::RunPairStarted {\n pair_id,\n target: record.target.clone(),\n actor: actor.clone(),\n });\n\n interrupt_handle.interrupt(actor);\n self.emitter.emit(&Event::AgentPairSystemMessage {\n node_id: record.target.stage_id.node_id().to_string(),\n visit: record.target.stage_id.visit(),\n session_id: session_id.clone(),\n pair_id,\n kind: PairSystemMessageKind::HumanJoined,\n text: text.to_string(),\n });\n\n *active_pair = Some(ActivePair {\n record: record.clone(),\n session_id,\n });\n Ok(record)\n}\n```\n\nAdjust imports if `Arc` is not already in scope.\n\n- [ ] **Step 3: Update `send_pair_message`**\n\nUse `pair.session_id` and `pair.record.target.stage_id` instead of `pair.record.target.agent_session_id`:\n\n```rust\nlet target = &pair.record.target;\nlet session_id = pair.session_id.clone();\nlet active = self.active.read().expect(\"active lock poisoned\");\nlet Some(entry) = active.get(&target.stage_id) else {\n return Err(PairControlError::TargetNotActive);\n};\nif entry.session_id != session_id {\n return Err(PairControlError::TargetNotActive);\n}\nlet Some(pair_handle) = entry.pair_handle.as_ref() else {\n return Err(PairControlError::TargetNotActive);\n};\n```\n\nEmit `AgentPairUserMessage` with derived node/visit:\n\n```rust\nself.emitter.emit(&Event::AgentPairUserMessage {\n node_id: target.stage_id.node_id().to_string(),\n visit: target.stage_id.visit(),\n session_id,\n pair_id,\n message_id,\n client_message_id: client_message_id.clone(),\n text: text.clone(),\n actor,\n});\n```\n\nReturn stage-based message record:\n\n```rust\nOk(PairMessageRecord {\n message_id,\n client_message_id,\n pair_id,\n run_id: pair.record.run_id,\n stage_id: target.stage_id.clone(),\n text,\n accepted_at: Utc::now(),\n})\n```\n\n- [ ] **Step 4: Update `end_pair`**\n\nUse `pair.session_id` when checking the current active entry and emitting `AgentPairSystemMessage`.\n\nThe `AgentPairSystemMessage` node data should derive from `target.stage_id`:\n\n```rust\nnode_id: target.stage_id.node_id().to_string(),\nvisit: target.stage_id.visit(),\nsession_id: session_id.clone(),\n```\n\n- [ ] **Step 5: Update session-ended cleanup**\n\nChange `pair_is_active_for` and `end_active_pair_for_target` so they compare:\n\n```rust\npair.record.target.stage_id == *stage_id && pair.session_id == session_id\n```\n\nDo not compare against public target fields that no longer exist.\n\n- [ ] **Step 6: Update steering hub tests**\n\nIn the `#[cfg(test)]` helpers at the bottom of `steering_hub.rs`, change `pair_target` to construct:\n\n```rust\nPairTarget {\n stage_id: stage_id.clone(),\n node_label: stage_id.node_id().to_string(),\n}\n```\n\nUpdate assertions that previously expected `target.agent_session_id`.\n\n- [ ] **Step 7: Run steering hub tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-workflow steering_hub\n```\n\nExpected: pass after all public target field references are removed.\n\n## Task 6: Update Worker Control Protocol\n\n**Files:**\n- Modify: `lib/crates/fabro-interview/src/control_protocol.rs`\n- Modify: `lib/crates/fabro-cli/src/commands/run/runner.rs`\n\n- [ ] **Step 1: Keep `PairStart` target public**\n\n`WorkerControlMessage::PairStart` can continue to carry `PairTarget`, but that `PairTarget` is now public stage data:\n\n```rust\n#[serde(rename = \"pair.start\")]\nPairStart {\n run_id: RunId,\n pair_id: PairId,\n target: PairTarget,\n actor: Principal,\n},\n```\n\nThe worker does not receive `agent_session_id`; `SteeringHub::start_pair` resolves the active session for `target.stage_id`.\n\n- [ ] **Step 2: Update control protocol tests**\n\nIn `control_protocol.rs`, update the pair start fixture to:\n\n```rust\nPairTarget {\n stage_id: \"code@1\".parse().unwrap(),\n node_label: \"Code\".to_string(),\n}\n```\n\nAssert the serialized pair target does not contain public pair leaks. Scope this assertion to the `target` object, not the whole worker-control envelope, because envelopes/actors may legitimately grow unrelated metadata later:\n\n```rust\nlet json = serde_json::to_string(&envelope).unwrap();\nlet value: serde_json::Value = serde_json::from_str(&json).unwrap();\nlet target = &value[\"target\"];\nlet target_text = target.to_string();\nassert!(target_text.contains(\"stage_id\"));\nassert!(target_text.contains(\"node_label\"));\nassert!(!target_text.contains(\"agent_session_id\"));\nassert!(!target_text.contains(\"session_id\"));\nassert!(!target_text.contains(\"\\\"node_id\\\"\"));\nassert!(!target_text.contains(\"\\\"visit\\\"\"));\nassert!(!target_text.contains(\"provider\"));\nassert!(!target_text.contains(\"model\"));\n```\n\n- [ ] **Step 3: Confirm runner dispatch still delegates to hub**\n\nIn `lib/crates/fabro-cli/src/commands/run/runner.rs`, keep the `PairStart` arm as:\n\n```rust\nWorkerControlMessage::PairStart {\n run_id,\n pair_id,\n target,\n actor,\n} => {\n let _ = steering_hub.start_pair(run_id, pair_id, target, Some(actor));\n}\n```\n\n- [ ] **Step 4: Run interview protocol tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-interview control_protocol\n```\n\nExpected: pass with the simplified pair start JSON.\n\n## Task 7: Update Server Live Projection And Pair Handlers\n\n**Files:**\n- Modify: `lib/crates/fabro-server/src/server.rs`\n- Modify: `lib/crates/fabro-server/src/server/handler/pair.rs`\n- Modify: `lib/crates/fabro-server/src/server/tests.rs`\n\n- [ ] **Step 1: Update active API target projection**\n\nIn `lib/crates/fabro-server/src/server.rs`, when handling `AgentSessionActivated`, store public pair targets:\n\n```rust\nmanaged_run\n .active_api_targets\n .insert(stage_id.clone(), PairTarget {\n stage_id: stage_id.clone(),\n node_label: event\n .node_label\n .clone()\n .unwrap_or_else(|| stage_id.node_id().to_string()),\n });\n```\n\nKeep `active_steerable_stages: HashMap` as the session lease guard.\n\n- [ ] **Step 2: Update deactivation cleanup**\n\nIn the `AgentSessionDeactivated` branch, remove `active_api_targets` only when `active_steerable_stages` still points at the deactivating session:\n\n```rust\nif managed_run\n .active_steerable_stages\n .get(stage_id)\n .is_some_and(|current| current == session_id)\n{\n managed_run.active_steerable_stages.remove(stage_id);\n managed_run.active_api_targets.remove(stage_id);\n}\n```\n\nThis preserves the stale-deactivation protection previously provided by `target.agent_session_id`.\n\n- [ ] **Step 3: Change pair target lookup to stage id**\n\nIn `lib/crates/fabro-server/src/server/handler/pair.rs`, change `pair_target_and_transport` to accept `&StageId`:\n\n```rust\nfn pair_target_and_transport(\n state: &AppState,\n id: &RunId,\n stage_id: &StageId,\n) -> Result<(PairTarget, Option), Response> {\n let runs = state.runs.lock().expect(\"runs lock poisoned\");\n let Some(run) = runs.get(id) else {\n return Err(ApiError::not_found(\"Run not found.\").into_response());\n };\n reject_unpairable_status(run.status)?;\n let Some(target) = run.active_api_targets.get(stage_id) else {\n return Err(pair_conflict(\n \"Requested pair target is not active.\",\n \"pair_target_not_active\",\n ));\n };\n Ok((target.clone(), run.answer_transport.clone()))\n}\n```\n\n- [ ] **Step 4: Change start pair handler request**\n\nIn the `start_pair` HTTP handler, replace:\n\n```rust\nJson(req): Json,\n```\n\nusage of `req.target` with:\n\n```rust\nlet (target, transport) = match pair_target_and_transport(state.as_ref(), &id, &req.stage_id) {\n Ok(result) => result,\n Err(response) => return response,\n};\n```\n\nThe response remains `PairRecord`.\n\n- [ ] **Step 5: Keep reconstructed pair windows stage-only**\n\nKeep the private pair window struct free of session identifiers:\n\n```rust\nstruct PairWindow {\n record: PairRecord,\n start_seq: u32,\n end_seq: Option,\n}\n```\n\nIn `reconstruct_pair_windows`, reconstruct from `RunPairStarted`, `RunPairEnded`, and `RunPairFailed` only. Do not read or store `event.session_id` for pair lifecycle windows.\n\n- [ ] **Step 6: Update transcript matching**\n\nChange transcript matching to use the pair window sequence range plus `stage_id`. Do not add old-history fallback behavior for previous `agent_session_id`-based pair records; this is a greenfield API surface and fixtures should be updated to the new model.\n\nUse this logic for assistant/tool/error/warning events:\n\n```rust\nfn event_matches_pair_target(pair: &PairRecord, event: &fabro_types::RunEvent) -> bool {\n event.stage_id.as_ref() == Some(&pair.target.stage_id)\n}\n```\n\n`transcript_page` already restricts scanned events to `window.start_seq..=window.end_seq`, so `stage_id` is enough for assistant/tool/error/warning events. Pair-specific user/system entries still match by `pair_id`.\n\nWhen constructing `PairTranscriptAssistantMessage`, do not copy `props.model` into the pair transcript response. Keep only `text` and `tool_call_count` from the assistant event.\n\n- [ ] **Step 7: Add transcript stage/window regression test**\n\nIn `lib/crates/fabro-server/src/server/handler/pair.rs` tests, add a regression test for the new stage/window transcript matching. The fixture should store events in this order:\n\n```text\nseq 1: agent.message for stage code@1 before pair start\nseq 2: run.pair.started for pair_id pair_1 target code@1\nseq 3: agent.message for stage code@1 inside pair window\nseq 4: agent.tool.started for stage code@1 inside pair window\nseq 5: agent.tool.completed for stage code@1 inside pair window\nseq 6: run.pair.ended for pair_id pair_1\nseq 7: agent.message for stage code@1 after pair end\n```\n\nCall the transcript projection for `pair_1` and assert:\n\n```rust\nlet assistant_count = response\n .data\n .iter()\n .filter(|entry| matches!(entry, PairTranscriptEntry::AssistantMessage(_)))\n .count();\nlet tool_count = response\n .data\n .iter()\n .filter(|entry| matches!(entry, PairTranscriptEntry::ToolCall(_)))\n .count();\nassert_eq!(assistant_count, 1);\nassert_eq!(tool_count, 2);\n```\n\nAlso assert the transcript text contains the inside-window assistant message and does not contain the before-start or after-end assistant messages. Do not add session-id filtering to make this pass; the intended behavior is stage plus pair window.\n\n- [ ] **Step 8: Update server tests**\n\nIn `lib/crates/fabro-server/src/server/tests.rs` and `handler/pair.rs` test modules, replace old target fixtures with:\n\n```rust\nPairTarget {\n stage_id: \"code@1\".parse().unwrap(),\n node_label: \"Code\".to_string(),\n}\n```\n\nAdd the same negative assertion on every public pair HTTP response covered by tests: status, start, get, message acknowledgement, and transcript:\n\n```rust\nassert!(!body.to_string().contains(\"agent_session_id\"));\nassert!(!body.to_string().contains(\"session_id\"));\nassert!(!body.to_string().contains(\"provider\"));\nassert!(!body.to_string().contains(\"model\"));\nassert!(!body.to_string().contains(\"\\\"node_id\\\"\"));\nassert!(!body.to_string().contains(\"\\\"visit\\\"\"));\n```\n\n- [ ] **Step 9: Run server pair tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-server pair\n```\n\nExpected: pass with stage-only pair request/response JSON.\n\n## Task 8: Update Fabro Client\n\n**Files:**\n- Modify: `lib/crates/fabro-client/src/client.rs`\n\n- [ ] **Step 1: Change start pair method**\n\nChange the client method from selector-based:\n\n```rust\npub async fn start_run_pair(\n &self,\n run_id: &RunId,\n target: PairTargetSelector,\n) -> Result\n```\n\nto stage-based:\n\n```rust\npub async fn start_run_pair(\n &self,\n run_id: &RunId,\n stage_id: StageId,\n) -> Result {\n let body = PairStartRequest { stage_id };\n let response = self\n .send_api(|client| {\n let body = body.clone();\n async move {\n client\n .start_run_pair()\n .id(run_id.to_string())\n .body(body)\n .send()\n .await\n }\n })\n .await?;\n convert_type(response.into_inner())\n}\n```\n\nAdd `StageId` to the imports from `fabro_types`.\n\n- [ ] **Step 2: Remove selector imports**\n\nRemove `PairTargetSelector` imports from `fabro-client` if no longer used.\n\n- [ ] **Step 3: Run client build**\n\nRun:\n\n```bash\ncargo build -p fabro-client\n```\n\nExpected: pass after all client callers use `StageId`.\n\n## Task 9: Add `fabro_run_pair` MCP Tool\n\n**Files:**\n- Create: `lib/crates/fabro-mcp-server/src/run_tools/pair.rs`\n- Modify: `lib/crates/fabro-mcp-server/src/run_tools.rs`\n- Modify: `lib/crates/fabro-mcp-server/src/server.rs`\n\n- [ ] **Step 1: Create pair tool action and params**\n\nCreate `lib/crates/fabro-mcp-server/src/run_tools/pair.rs` with:\n\n```rust\nuse std::sync::Arc;\n\nuse fabro_client::Client;\nuse fabro_types::{PairId, PairMessageRequest, StageId};\nuse schemars::JsonSchema;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{Value, json};\n\nuse super::common::{ToolError, ToolResult};\n\nconst MAX_PAIR_MESSAGE_BYTES: usize = 8192;\n\n#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema)]\n#[serde(rename_all = \"snake_case\")]\npub(crate) enum RunPairAction {\n Status,\n Start,\n Get,\n Message,\n End,\n Transcript,\n}\n\n#[derive(Debug, Deserialize, JsonSchema)]\npub(crate) struct FabroRunPairParams {\n pub(crate) action: RunPairAction,\n pub(crate) run_id: Option,\n pub(crate) pair_id: Option,\n pub(crate) stage_id: Option,\n pub(crate) text: Option,\n pub(crate) client_message_id: Option,\n pub(crate) since_seq: Option,\n pub(crate) limit: Option,\n}\n```\n\n`run_id` is intentionally `Option` so missing input reaches `TryFrom` and returns the tool-level `run_id is required` error instead of a serde deserialization error. The validator still requires it for every action.\n\n- [ ] **Step 2: Add validated action types**\n\nAdd:\n\n```rust\n#[derive(Debug)]\npub(crate) struct ValidatedPairRun {\n pub(crate) run_id: String,\n pub(crate) action: ValidatedPairAction,\n}\n\n#[derive(Debug)]\npub(crate) enum ValidatedPairAction {\n Status,\n Start { stage_id: StageId },\n Get { pair_id: PairId },\n Message {\n pair_id: PairId,\n text: String,\n client_message_id: Option,\n },\n End { pair_id: PairId },\n Transcript {\n pair_id: PairId,\n since_seq: Option,\n limit: Option,\n },\n}\n```\n\nImplement `TryFrom for ValidatedPairRun` with these exact validation errors:\n\n```text\nrun_id is required\nstage_id is required for action start\npair_id is required for action get\npair_id is required for action message\npair_id is required for action end\npair_id is required for action transcript\ntext is required for action message\ntext must be at most 8192 bytes for action message\ninvalid stage_id for action start: \ninvalid pair_id for action : \n```\n\nMake `run_id` optional in `FabroRunPairParams` so missing input reaches `TryFrom` instead of failing serde deserialization before the tool can return the planned error:\n\n```rust\nlet Some(run_id) = params\n .run_id\n .as_deref()\n .map(str::trim)\n .filter(|run_id| !run_id.is_empty())\nelse {\n return Err(ToolError::message(\"run_id is required\"));\n};\nlet run_id = run_id.to_string();\n```\n\nParse `stage_id` with `raw.parse::()`. Parse `pair_id` with `raw.parse::()`.\n\n- [ ] **Step 3: Add execution function**\n\nAdd:\n\n```rust\n#[derive(Debug, Serialize, JsonSchema)]\npub(crate) struct PairRunResult {\n pub(crate) run_id: String,\n pub(crate) action: RunPairAction,\n pub(crate) result: Value,\n}\n\npub(crate) async fn pair_run(\n client: Arc,\n params: ValidatedPairRun,\n) -> ToolResult {\n let run_id = client\n .resolve_run(¶ms.run_id)\n .await\n .map_err(|err| ToolError::from_anyhow(&err))?\n .id;\n\n let (action, result) = match params.action {\n ValidatedPairAction::Status => (\n RunPairAction::Status,\n json!(client.get_run_pair_status(&run_id).await.map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::Start { stage_id } => (\n RunPairAction::Start,\n json!(client.start_run_pair(&run_id, stage_id).await.map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::Get { pair_id } => (\n RunPairAction::Get,\n json!(client.get_run_pair(&run_id, &pair_id).await.map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::Message {\n pair_id,\n text,\n client_message_id,\n } => (\n RunPairAction::Message,\n json!(client\n .send_run_pair_message(\n &run_id,\n &pair_id,\n PairMessageRequest {\n text,\n client_message_id,\n },\n )\n .await\n .map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::End { pair_id } => (\n RunPairAction::End,\n json!(client.end_run_pair(&run_id, &pair_id).await.map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n ValidatedPairAction::Transcript {\n pair_id,\n since_seq,\n limit,\n } => (\n RunPairAction::Transcript,\n json!(client\n .get_run_pair_transcript(&run_id, &pair_id, since_seq, limit)\n .await\n .map_err(|err| ToolError::from_anyhow(&err))?),\n ),\n };\n\n Ok(PairRunResult {\n run_id: run_id.to_string(),\n action,\n result,\n })\n}\n```\n\nIf rustfmt expands these match arms, accept the formatted output.\n\n- [ ] **Step 4: Add text summary**\n\nAdd:\n\n```rust\npub(crate) fn pair_run_text(result: &PairRunResult) -> String {\n match result.action {\n RunPairAction::Status => format!(\"read pair status for Fabro run {}\", result.run_id),\n RunPairAction::Start => format!(\"started pair for Fabro run {}\", result.run_id),\n RunPairAction::Get => format!(\"read pair for Fabro run {}\", result.run_id),\n RunPairAction::Message => format!(\"sent pair message for Fabro run {}\", result.run_id),\n RunPairAction::End => format!(\"ended pair for Fabro run {}\", result.run_id),\n RunPairAction::Transcript => {\n format!(\"read pair transcript for Fabro run {}\", result.run_id)\n }\n }\n}\n```\n\n- [ ] **Step 5: Add validation tests**\n\nIn `pair.rs`, add unit tests for:\n\n```rust\n#[test]\nfn missing_or_blank_run_id_returns_tool_error() { ... }\n\n#[test]\nfn start_requires_stage_id() { ... }\n\n#[test]\nfn message_requires_pair_id_and_text() { ... }\n\n#[test]\nfn message_rejects_overlong_text() { ... }\n\n#[test]\nfn transcript_requires_pair_id() { ... }\n```\n\nUse `ValidatedPairRun::try_from(...)` and assert the exact error text contains the messages listed in Step 2.\n\n- [ ] **Step 6: Export pair tool functions**\n\nIn `lib/crates/fabro-mcp-server/src/run_tools.rs`, add:\n\n```rust\nmod pair;\n```\n\nand:\n\n```rust\npub(crate) use pair::{\n FabroRunPairParams, ValidatedPairRun, pair_run, pair_run_text,\n};\n```\n\n- [ ] **Step 7: Register MCP tool**\n\nIn `lib/crates/fabro-mcp-server/src/server.rs`, add:\n\n```rust\n#[tool(\n name = \"fabro_run_pair\",\n description = \"Inspect, start, message, end, or read transcript for a live Fabro run pairing session.\"\n)]\nasync fn fabro_run_pair(\n &self,\n params: Parameters,\n) -> Result {\n let params = match run_tools::ValidatedPairRun::try_from(params.0) {\n Ok(params) => params,\n Err(err) => return Ok(run_tools::error_result(err)),\n };\n let client = match self.client().await {\n Ok(client) => client,\n Err(err) => return Ok(run_tools::error_result(err)),\n };\n match run_tools::pair_run(client, params).await {\n Ok(result) => run_tools::success_result(&result, run_tools::pair_run_text(&result)),\n Err(err) => Ok(run_tools::error_result(err)),\n }\n}\n```\n\n- [ ] **Step 8: Add MCP registration and schema tests**\n\nIn `lib/crates/fabro-mcp-server/src/server.rs`, add a `#[cfg(test)]` module that constructs `FabroMcpServer` and inspects `tool_router.list_all()`:\n\n```rust\n#[cfg(test)]\nmod tests {\n use std::path::PathBuf;\n use std::sync::Arc;\n\n use serde_json::Value;\n\n use super::*;\n use crate::FabroMcpServerSettings;\n\n #[test]\n fn fabro_run_pair_tool_is_registered_with_stage_based_schema() {\n let settings = FabroMcpServerSettings {\n cwd: PathBuf::from(\".\"),\n config_path: PathBuf::from(\"fabro.toml\"),\n client_factory: Arc::new(|| {\n Box::pin(async { panic!(\"client should not be constructed while listing tools\") })\n }),\n };\n let server = FabroMcpServer::new(Arc::new(settings));\n let tools = server.tool_router.list_all();\n let tool = tools\n .iter()\n .find(|tool| tool.name.as_ref() == \"fabro_run_pair\")\n .expect(\"fabro_run_pair should be registered\");\n let schema = Value::Object(tool.input_schema.as_ref().clone());\n let schema_text = schema.to_string();\n\n assert!(schema_text.contains(\"stage_id\"));\n assert!(!schema_text.contains(\"agent_session_id\"));\n assert!(!schema_text.contains(\"session_id\"));\n assert!(!schema_text.contains(\"PairTargetSelector\"));\n assert!(!schema_text.contains(\"\\\"target\\\"\"));\n assert!(!schema_text.contains(\"provider\"));\n assert!(!schema_text.contains(\"model\"));\n assert!(!schema_text.contains(\"\\\"node_id\\\"\"));\n assert!(!schema_text.contains(\"\\\"visit\\\"\"));\n }\n}\n```\n\n- [ ] **Step 9: Add MCP result leakage tests**\n\nIn `lib/crates/fabro-mcp-server/src/run_tools/pair.rs`, add unit tests that serialize representative `PairRunResult` values for `status`, `start`, `message`, and `transcript` and assert no public result includes forbidden fields:\n\n```rust\nfn assert_no_public_pair_leaks(value: &serde_json::Value) {\n let text = value.to_string();\n assert!(!text.contains(\"agent_session_id\"));\n assert!(!text.contains(\"session_id\"));\n assert!(!text.contains(\"provider\"));\n assert!(!text.contains(\"model\"));\n assert!(!text.contains(\"\\\"node_id\\\"\"));\n assert!(!text.contains(\"\\\"visit\\\"\"));\n}\n```\n\nUse public pair fixture values that contain `PairTarget { stage_id, node_label }` only.\n\n- [ ] **Step 10: Run MCP server tests**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-mcp-server\n```\n\nExpected: pass after validation tests and tool registration compile.\n\n## Task 10: Regenerate TypeScript API Client\n\n**Files:**\n- Modify generated files under: `lib/packages/fabro-api-client`\n\n- [ ] **Step 1: Generate client**\n\nRun:\n\n```bash\ncd lib/packages/fabro-api-client && bun run generate\n```\n\nExpected: TypeScript client updates pair DTOs to stage-based shape.\n\n- [ ] **Step 2: Inspect generated diff**\n\nRun:\n\n```bash\ngit diff -- lib/packages/fabro-api-client | sed -n '1,240p'\n```\n\nExpected: pair schemas remove `agent_session_id`, `session_id`, `node_id`, `visit`, `provider`, and `model`; `PairStartRequest` gains `stage_id`.\n\n- [ ] **Step 3: Verify generated pair DTOs do not leak internals**\n\nRun:\n\n```bash\nrg -n \"PairTargetSelector|agent_session_id|session_id|['\\\"]provider['\\\"]|[[:space:]]provider:|['\\\"]model['\\\"]|[[:space:]]model:|['\\\"]node_id['\\\"]|[[:space:]]node_id:|['\\\"]visit['\\\"]|[[:space:]]visit:\" \\\n lib/packages/fabro-api-client/src/models/pair-*.ts \\\n lib/packages/fabro-api-client/src/models/run-pair-status-response.ts \\\n lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts\n```\n\nExpected: no matches in generated pair DTOs or human-in-the-loop pair method signatures. If `rg` reports a generated pair file that should have been deleted, remove the stale generated file through the generator output or the normal generated-client cleanup path.\n\n## Task 11: Update Frontend Consumers\n\n**Files:**\n- Search and modify as needed under: `apps/fabro-web`\n\n- [ ] **Step 1: Search frontend pair consumers**\n\nRun:\n\n```bash\nrg -n \"startRunPair|getRunPairStatus|getRunPairTranscript|sendRunPairMessage|PairStartRequest|PairTargetSelector|agent_session_id|session_id|['\\\"]provider['\\\"]|[[:space:]]provider:|['\\\"]model['\\\"]|[[:space:]]model:|['\\\"]node_id['\\\"]|[[:space:]]node_id:|['\\\"]visit['\\\"]|[[:space:]]visit:\" apps/fabro-web\n```\n\nExpected: review every match manually. Update real pair API consumers to use `stage_id` and the simplified generated DTOs. Ignore unrelated uses where the match is not part of the pair API, such as generic agent event rendering or text containing the word \"pair\".\n\n- [ ] **Step 2: Update frontend pair request construction**\n\nIf `apps/fabro-web` constructs a pair start request, change it from:\n\n```ts\nawait api.startRunPair(runId, {\n target: {\n stage_id: target.stage_id,\n agent_session_id: target.agent_session_id,\n },\n});\n```\n\nto:\n\n```ts\nawait api.startRunPair(runId, {\n stage_id: target.stage_id,\n});\n```\n\nIf there are no frontend pair API consumers, record that in the implementation notes and leave frontend source unchanged.\n\n- [ ] **Step 3: Run frontend typecheck**\n\nRun:\n\n```bash\ncd apps/fabro-web && bun run typecheck\n```\n\nExpected: pass.\n\n- [ ] **Step 4: Run frontend tests**\n\nRun:\n\n```bash\ncd apps/fabro-web && bun test\n```\n\nExpected: pass.\n\n## Task 12: Final Cleanup And Verification\n\n**Files:**\n- Search all Rust/OpenAPI/TS files for removed public fields.\n\n- [ ] **Step 1: Check public surfaces for leaked pair internals**\n\nRun:\n\n```bash\nrg -n \"PairTargetSelector|agent_session_id|session_id|['\\\"]provider['\\\"]|[[:space:]]provider:|['\\\"]model['\\\"]|[[:space:]]model:|['\\\"]node_id['\\\"]|[[:space:]]node_id:|['\\\"]visit['\\\"]|[[:space:]]visit:\" \\\n docs/public/api-reference/fabro-api.yaml \\\n lib/crates/fabro-types/src/pair.rs \\\n lib/crates/fabro-api/tests/pair_round_trip.rs \\\n lib/crates/fabro-api/tests/run_event_round_trip.rs \\\n lib/packages/fabro-api-client/src/models/pair-*.ts \\\n lib/packages/fabro-api-client/src/models/run-pair-status-response.ts \\\n lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts \\\n lib/crates/fabro-mcp-server/src/run_tools/pair.rs \\\n lib/crates/fabro-mcp-server/src/server.rs\n```\n\nExpected: no matches that expose those names through pair API schemas, pair DTOs, generated pair DTOs, or MCP pair params/results. Matches inside negative leakage assertions such as `assert!(!text.contains(\"session_id\"))` are expected and should be reviewed, not deleted.\n\n- [ ] **Step 2: Check internal runtime session usage separately**\n\nRun:\n\n```bash\nrg -n \"agent_session_id|session_id\" \\\n lib/crates/fabro-workflow/src/steering_hub.rs \\\n lib/crates/fabro-workflow/src/handler/llm \\\n lib/crates/fabro-server/src/server.rs \\\n lib/crates/fabro-server/src/server/handler/pair.rs \\\n lib/crates/fabro-interview/src/control_protocol.rs \\\n lib/crates/fabro-cli/src/commands/run/runner.rs\n```\n\nExpected: internal `session_id` usage remains where it protects live session leases, emits ordinary agent events, or routes active pair messages. `agent_session_id` should not remain unless it belongs to unrelated legacy tests that were not part of the pair API and have been consciously reviewed.\n\n- [ ] **Step 3: Run caller migration search**\n\nRun:\n\n```bash\nrg -n \"start_run_pair|PairStartRequest|PairTargetSelector|agent_session_id|\\\\.target\" \\\n lib apps docs/public/api-reference/fabro-api.yaml\n```\n\nExpected: review every match manually. Valid remaining matches include `PairRecord.target`, transcript entry `target`, public negative leakage assertions, and internal non-pair session handling. Invalid matches include selector-based `start_run_pair` calls, `PairStartRequest { target: ... }`, or public `agent_session_id` exposure.\n\n- [ ] **Step 4: Run focused test suite**\n\nRun:\n\n```bash\ncargo nextest run -p fabro-api pair_round_trip run_event_round_trip\ncargo nextest run -p fabro-workflow steering_hub\ncargo nextest run -p fabro-interview control_protocol\ncargo nextest run -p fabro-server pair\ncargo nextest run -p fabro-mcp-server\n(cd apps/fabro-web && bun run typecheck)\n(cd apps/fabro-web && bun test)\n```\n\nExpected: all pass.\n\n- [ ] **Step 5: Run workspace build**\n\nRun:\n\n```bash\ncargo build --workspace\n```\n\nExpected: pass.\n\n- [ ] **Step 6: Run formatting check**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 fmt --check --all\n```\n\nExpected: pass. If it fails, run `cargo +nightly-2026-04-14 fmt --all` and repeat the check.\n\n- [ ] **Step 7: Run clippy**\n\nRun:\n\n```bash\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\n```\n\nExpected: pass.\n\n- [ ] **Step 8: Run workspace tests**\n\nRun:\n\n```bash\ncargo nextest run --workspace\n```\n\nExpected: pass. If macOS reports `Too many open files`, rerun with:\n\n```bash\nulimit -n 4096 && cargo nextest run --workspace\n```\n\n## Acceptance Criteria\n\n- `POST /api/v1/runs/{id}/pair` accepts only `stage_id` for target selection.\n- `GET /api/v1/runs/{id}/pair` returns targets with only `stage_id` and `node_label`.\n- Pair records, message acknowledgements, transcript entries, public pair event bodies, generated pair DTOs, and MCP pair params/results do not expose `agent_session_id`, `session_id`, `provider`, `model`, raw `node_id`, or raw `visit`.\n- Runtime still uses session IDs internally to avoid stale session cleanup and route active pair messages, but that state does not cross the public pair API or MCP boundary.\n- `fabro_run_pair` is registered with actions `status`, `start`, `get`, `message`, `end`, and `transcript`.\n- `fabro_run_pair` input schema contains `stage_id` and does not contain selector/session fields.\n- MCP callers can start pairing with `run_id + stage_id`.\n- Generated Rust and TypeScript API clients match the simplified OpenAPI contract.\n- Frontend pair consumers, if any, use the simplified generated DTOs.\n- Focused pair tests, MCP tests, frontend typecheck/tests, workspace build, fmt, clippy, and workspace tests pass.\n", + "internal.run_id": "01KS69T5X6B5RQ87DGT5BWS1JH", + "internal.fidelity": "compact", + "failure_class": "", + "thread.start.current_node": "toolchain", + "internal.retry_count.start": 0, + "graph.rankdir": "LR", + "outcome": "succeeded", + "internal.retry_count.toolchain": 0, + "failure_signature": "", + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "current_node": "toolchain", + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c", + "internal.thread_id": "start", + "internal.node_visit_count": 1, + "internal.work_dir": "/home/daytona/workspace/fabro" + }, + "node_outcomes": { + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null + }, + "start": { + "status": "succeeded", + "usage": null + } + }, + "next_node_id": "preflight_compile", + "node_visits": { + "toolchain": 1, + "start": 1 + } + }, + "diff": {} + } + ], "conclusion": null, "sandbox": { "provider": "daytona", @@ -537,5 +629,62 @@ "pull_request": null, "superseded_by": null, "pending_interviews": {}, - "stages": {} + "stages": { + "toolchain@1": { + "first_event_seq": 20, + "prompt": null, + "response": null, + "completion": null, + "provider_used": null, + "diff": null, + "script_invocation": { + "script": "command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "command": "exec 2>&1\ncommand -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "language": "shell" + }, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-21T22:17:24.015909Z", + "handler": "command", + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "state": "running" + }, + "start@1": { + "first_event_seq": 16, + "prompt": null, + "response": null, + "completion": { + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-05-21T22:17:24.015677Z" + }, + "provider_used": null, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-21T22:17:24.015535Z", + "handler": "start", + "duration_ms": 0, + "usage": { + "input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "reasoning_tokens": 0, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "state": "succeeded" + } + } } \ No newline at end of file diff --git a/stages/001-start@1/status.json b/stages/001-start@1/status.json new file mode 100644 index 000000000..49fa7694e --- /dev/null +++ b/stages/001-start@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": null, + "failure_reason": null, + "timestamp": "2026-05-21T22:17:24.015677Z" +} \ No newline at end of file diff --git a/stages/002-toolchain@1/script_invocation.json b/stages/002-toolchain@1/script_invocation.json new file mode 100644 index 000000000..92c244949 --- /dev/null +++ b/stages/002-toolchain@1/script_invocation.json @@ -0,0 +1,5 @@ +{ + "script": "command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "command": "exec 2>&1\ncommand -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "language": "shell" +} \ No newline at end of file