From b0bffdf7815da9b4aea8efab1180cdb7eee7d692 Mon Sep 17 00:00:00 2001 From: Fabro Date: Thu, 21 May 2026 18:17:22 -0400 Subject: [PATCH] =?UTF-8?q?init=20run=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 --- graph.fabro | 37 ++++ run.json | 541 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 578 insertions(+) create mode 100644 graph.fabro create mode 100644 run.json diff --git a/graph.fabro b/graph.fabro new file mode 100644 index 000000000..bfd5da463 --- /dev/null +++ b/graph.fabro @@ -0,0 +1,37 @@ +digraph ImplementPlan { + graph [ + goal="Implement and simplify", + model_stylesheet=" + * { model: claude-opus-4-7; } + " + ] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + toolchain [label="Toolchain", shape=parallelogram, 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", max_retries=0] + preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] + preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] + fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] + implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."] + simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] + simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] + verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"] + fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3] + fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0] + + start -> toolchain + toolchain -> preflight_compile [condition="outcome=succeeded"] + toolchain -> exit + preflight_compile -> preflight_lint [condition="outcome=succeeded"] + preflight_compile -> exit + preflight_lint -> implement [condition="outcome=succeeded"] + preflight_lint -> fix_lints + fix_lints -> preflight_lint + implement -> simplify_opus -> simplify_gpt -> verify + verify -> fmt [condition="outcome=succeeded"] + verify -> fixup + fixup -> verify + fmt -> exit +} diff --git a/run.json b/run.json new file mode 100644 index 000000000..7a2d91c46 --- /dev/null +++ b/run.json @@ -0,0 +1,541 @@ +{ + "title": "Stage-Based Pairing API And MCP Tool Implementation Plan", + "spec": { + "run_id": "01KS69T5X6B5RQ87DGT5BWS1JH", + "settings": { + "project": { + "name": null, + "description": null, + "metadata": {} + }, + "workflow": { + "name": null, + "description": null, + "graph": "workflow.fabro", + "metadata": {} + }, + "run": { + "goal": { + "type": "inline", + "value": "# 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" + }, + "working_dir": null, + "metadata": {}, + "inputs": {}, + "model": { + "provider": "anthropic", + "name": "claude-sonnet-4-6", + "fallbacks": [], + "controls": { + "reasoning_effort": null, + "speed": null + } + }, + "git": { + "author": null + }, + "prepare": { + "commands": [], + "timeout_ms": 300000 + }, + "execution": { + "mode": "normal", + "approval": "prompt" + }, + "checkpoint": { + "exclude_globs": [] + }, + "clone": { + "enabled": true + }, + "run_branch": { + "enabled": true, + "push": true + }, + "meta_branch": { + "enabled": true, + "push": true + }, + "sandbox": { + "provider": "daytona", + "preserve": false, + "stop_on_terminal": true, + "devcontainer": false, + "env": {}, + "docker": { + "image": "buildpack-deps:noble", + "network_mode": null, + "memory_limit": 4000000000, + "cpu_quota": 200000, + "env_vars": {} + }, + "daytona": { + "auto_stop_interval": 30, + "labels": { + "repo": "fabro-sh/fabro" + }, + "volumes": [], + "snapshot": { + "name": "fabro-v11", + "cpu": 8, + "memory_gb": 16, + "disk_gb": 20, + "dockerfile": { + "type": "inline", + "value": "FROM ubuntu:24.04\n\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 \\\n xvfb xfce4 xfce4-terminal x11vnc novnc dbus-x11 \\\n libx11-6 libxrandr2 libxext6 libxrender1 libxfixes3 libxss1 libxtst6 libxi6 \\\n && rm -rf /var/lib/apt/lists/*\n\n# Install real Chromium (not the snap stub) via xtradeb PPA\nRUN apt-get update && apt-get install -y --no-install-recommends \\\n software-properties-common curl gnupg \\\n && add-apt-repository -y ppa:xtradeb/apps \\\n && apt-get update \\\n && apt-get install -y --no-install-recommends chromium \\\n && rm -rf /var/lib/apt/lists/*\n\n# Wrapper: Chromium needs --no-sandbox when running as root in a container,\n# and --disable-dev-shm-usage avoids crashes from small /dev/shm\nRUN printf '#!/bin/bash\\nexec /usr/bin/chromium --no-sandbox --disable-dev-shm-usage \"$@\"\\n' \\\n > /usr/local/bin/chromium-wrapper \\\n && chmod +x /usr/local/bin/chromium-wrapper\n\n# Make the wrapper the default in the system .desktop file and via alternatives\nRUN sed -i 's|^Exec=.*|Exec=/usr/local/bin/chromium-wrapper %U|' \\\n /usr/share/applications/chromium.desktop \\\n && update-alternatives --install /usr/bin/x-www-browser x-www-browser \\\n /usr/local/bin/chromium-wrapper 100\n\n# Tell XFCE's exo-open that Chromium is the WebBrowser helper (system-wide)\nRUN mkdir -p /etc/xdg/xfce4 /usr/share/xfce4/helpers \\\n && printf 'WebBrowser=custom-WebBrowser\\n' > /etc/xdg/xfce4/helpers.rc \\\n && printf '[Desktop Entry]\\n\\\nVersion=1.0\\n\\\nType=X-XFCE-Helper\\n\\\nName=Chromium\\n\\\nIcon=chromium\\n\\\nX-XFCE-Category=WebBrowser\\n\\\nX-XFCE-CommandsWithParameter=/usr/local/bin/chromium-wrapper \"%%s\"\\n\\\nX-XFCE-Commands=/usr/local/bin/chromium-wrapper\\n' \\\n > /usr/share/xfce4/helpers/custom-WebBrowser.desktop\n\n# GitHub CLI\nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \\\n | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \\\n && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" \\\n | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \\\n && apt-get update && apt-get install -y --no-install-recommends gh \\\n && rm -rf /var/lib/apt/lists/*\n\n# Rust\nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y\nENV PATH=\"/root/.cargo/bin:${PATH}\"\nRUN rustup toolchain install nightly-2026-04-14 --profile minimal --component clippy,rustfmt\nRUN cargo install cargo-nextest --locked\nENV CARGO_INCREMENTAL=0\n\n# Bun\nRUN curl -fsSL https://bun.sh/install | bash\nENV PATH=\"/root/.bun/bin:${PATH}\"\n\nWORKDIR /root\n" + } + }, + "network": null + } + }, + "notifications": {}, + "interviews": { + "provider": null, + "slack": null + }, + "agent": { + "permissions": null, + "mcps": {} + }, + "hooks": [], + "scm": { + "provider": null, + "owner": null, + "repository": null, + "github": null + }, + "pull_request": { + "enabled": true, + "draft": false, + "auto_merge": false, + "merge_strategy": "squash" + }, + "artifacts": { + "include": [] + }, + "integrations": { + "github": { + "permissions": {} + } + } + } + }, + "graph": { + "name": "ImplementPlan", + "nodes": { + "start": { + "id": "start", + "attrs": { + "label": { + "String": "Start" + }, + "shape": { + "String": "Mdiamond" + }, + "provider": { + "String": "anthropic" + }, + "model": { + "String": "claude-opus-4-7" + } + } + }, + "fixup": { + "id": "fixup", + "attrs": { + "provider": { + "String": "anthropic" + }, + "label": { + "String": "Fixup" + }, + "max_visits": { + "Integer": 3 + }, + "prompt": { + "String": "The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors." + }, + "model": { + "String": "claude-opus-4-7" + } + } + }, + "toolchain": { + "id": "toolchain", + "attrs": { + "shape": { + "String": "parallelogram" + }, + "provider": { + "String": "anthropic" + }, + "max_retries": { + "Integer": 0 + }, + "model": { + "String": "claude-opus-4-7" + }, + "script": { + "String": "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" + }, + "label": { + "String": "Toolchain" + } + } + }, + "implement": { + "id": "implement", + "attrs": { + "label": { + "String": "Implement" + }, + "model": { + "String": "claude-opus-4-7" + }, + "prompt": { + "String": "Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD." + }, + "provider": { + "String": "anthropic" + } + } + }, + "exit": { + "id": "exit", + "attrs": { + "label": { + "String": "Exit" + }, + "model": { + "String": "claude-opus-4-7" + }, + "provider": { + "String": "anthropic" + }, + "shape": { + "String": "Msquare" + } + } + }, + "fmt": { + "id": "fmt", + "attrs": { + "script": { + "String": "cargo +nightly-2026-04-14 fmt --all 2>&1" + }, + "label": { + "String": "Format" + }, + "shape": { + "String": "parallelogram" + }, + "provider": { + "String": "anthropic" + }, + "model": { + "String": "claude-opus-4-7" + }, + "max_retries": { + "Integer": 0 + } + } + }, + "verify": { + "id": "verify", + "attrs": { + "script": { + "String": "cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1" + }, + "provider": { + "String": "anthropic" + }, + "label": { + "String": "Verify" + }, + "shape": { + "String": "parallelogram" + }, + "model": { + "String": "claude-opus-4-7" + }, + "retry_target": { + "String": "fixup" + }, + "goal_gate": { + "Boolean": true + } + } + }, + "simplify_opus": { + "id": "simplify_opus", + "attrs": { + "prompt": { + "String": "# Simplify: Code Review and Cleanup\n\nReview changes vs. origin for reuse, quality, and efficiency. Fix any issues found.\n\n## Phase 1: Identify Changes\n\nRun git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation.\n\n## Phase 2: Launch Three Review Agents in Parallel\n\nUse the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.\n\n### Agent 1: Code Reuse Review\n\nFor each change:\n\n1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.\n2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.\n3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.\n\nNote: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.\n\n### Agent 2: Code Quality Review\n\nReview the same changes for hacky patterns:\n\n1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls\n2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones\n3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction\n4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries\n5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase\n\nNote: This is a greenfield app, so be aggressive in optimizing quality.\n\n### Agent 3: Efficiency Review\n\nReview the same changes for efficiency:\n\n1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns\n2. Missed concurrency: independent operations run sequentially when they could run in parallel\n3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths\n4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error\n5. Memory: unbounded data structures, missing cleanup, event listener leaks\n6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one\n\n## Phase 3: Fix Issues\n\nWait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.\n\nWhen done, briefly summarize what was fixed (or confirm the code was already clean)." + }, + "model": { + "String": "claude-opus-4-7" + }, + "label": { + "String": "Simplify (Opus)" + }, + "provider": { + "String": "anthropic" + } + } + }, + "simplify_gpt": { + "id": "simplify_gpt", + "attrs": { + "provider": { + "String": "openai" + }, + "label": { + "String": "Simplify (GPT-55)" + }, + "prompt": { + "String": "# Simplify: Code Review and Cleanup\n\nReview changes vs. origin for reuse, quality, and efficiency. Fix any issues found.\n\n## Phase 1: Identify Changes\n\nRun git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation.\n\n## Phase 2: Launch Three Review Agents in Parallel\n\nUse the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.\n\n### Agent 1: Code Reuse Review\n\nFor each change:\n\n1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones.\n2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead.\n3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates.\n\nNote: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it.\n\n### Agent 2: Code Quality Review\n\nReview the same changes for hacky patterns:\n\n1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls\n2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones\n3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction\n4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries\n5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase\n\nNote: This is a greenfield app, so be aggressive in optimizing quality.\n\n### Agent 3: Efficiency Review\n\nReview the same changes for efficiency:\n\n1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns\n2. Missed concurrency: independent operations run sequentially when they could run in parallel\n3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths\n4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error\n5. Memory: unbounded data structures, missing cleanup, event listener leaks\n6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one\n\n## Phase 3: Fix Issues\n\nWait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it.\n\nWhen done, briefly summarize what was fixed (or confirm the code was already clean)." + }, + "model": { + "String": "gpt-5.5" + } + } + }, + "preflight_lint": { + "id": "preflight_lint", + "attrs": { + "label": { + "String": "Preflight Lint" + }, + "shape": { + "String": "parallelogram" + }, + "model": { + "String": "claude-opus-4-7" + }, + "script": { + "String": "cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1" + }, + "max_retries": { + "Integer": 0 + }, + "provider": { + "String": "anthropic" + } + } + }, + "preflight_compile": { + "id": "preflight_compile", + "attrs": { + "provider": { + "String": "anthropic" + }, + "label": { + "String": "Preflight Compile" + }, + "model": { + "String": "claude-opus-4-7" + }, + "shape": { + "String": "parallelogram" + }, + "script": { + "String": "cargo check -q --workspace 2>&1" + }, + "max_retries": { + "Integer": 0 + } + } + }, + "fix_lints": { + "id": "fix_lints", + "attrs": { + "prompt": { + "String": "The preflight lint step failed. Read the build output from context and fix all clippy lint warnings." + }, + "max_visits": { + "Integer": 3 + }, + "provider": { + "String": "anthropic" + }, + "model": { + "String": "claude-opus-4-7" + }, + "label": { + "String": "Fix Lints" + } + } + } + }, + "edges": [ + { + "from": "start", + "to": "toolchain", + "attrs": {} + }, + { + "from": "toolchain", + "to": "preflight_compile", + "attrs": { + "condition": { + "String": "outcome=succeeded" + } + } + }, + { + "from": "toolchain", + "to": "exit", + "attrs": {} + }, + { + "from": "preflight_compile", + "to": "preflight_lint", + "attrs": { + "condition": { + "String": "outcome=succeeded" + } + } + }, + { + "from": "preflight_compile", + "to": "exit", + "attrs": {} + }, + { + "from": "preflight_lint", + "to": "implement", + "attrs": { + "condition": { + "String": "outcome=succeeded" + } + } + }, + { + "from": "preflight_lint", + "to": "fix_lints", + "attrs": {} + }, + { + "from": "fix_lints", + "to": "preflight_lint", + "attrs": {} + }, + { + "from": "implement", + "to": "simplify_opus", + "attrs": {} + }, + { + "from": "simplify_opus", + "to": "simplify_gpt", + "attrs": {} + }, + { + "from": "simplify_gpt", + "to": "verify", + "attrs": {} + }, + { + "from": "verify", + "to": "fmt", + "attrs": { + "condition": { + "String": "outcome=succeeded" + } + } + }, + { + "from": "verify", + "to": "fixup", + "attrs": {} + }, + { + "from": "fixup", + "to": "verify", + "attrs": {} + }, + { + "from": "fmt", + "to": "exit", + "attrs": {} + } + ], + "attrs": { + "rankdir": { + "String": "LR" + }, + "goal": { + "String": "# 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" + }, + "model_stylesheet": { + "String": "\n * { model: claude-opus-4-7; }\n " + } + } + }, + "graph_source": "digraph ImplementPlan {\n graph [\n goal=\"Implement and simplify\",\n model_stylesheet=\"\n * { model: claude-opus-4-7; }\n \"\n ]\n rankdir=LR\n\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n\n toolchain [label=\"Toolchain\", shape=parallelogram, 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\", max_retries=0]\n preflight_compile [label=\"Preflight Compile\", shape=parallelogram, script=\"cargo check -q --workspace 2>&1\", max_retries=0]\n preflight_lint [label=\"Preflight Lint\", shape=parallelogram, script=\"cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1\", max_retries=0]\n fix_lints [label=\"Fix Lints\", prompt=\"The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.\", max_visits=3]\n implement [label=\"Implement\", prompt=\"Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.\"]\n simplify_opus [label=\"Simplify (Opus)\", prompt=\"@prompts/simplify.md\"]\n simplify_gpt [label=\"Simplify (GPT-55)\", prompt=\"@prompts/simplify.md\", model=\"gpt-55\"]\n verify [label=\"Verify\", shape=parallelogram, script=\"cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1\", goal_gate=true, retry_target=\"fixup\"]\n fixup [label=\"Fixup\", prompt=\"The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.\", max_visits=3]\n fmt [label=\"Format\", shape=parallelogram, script=\"cargo +nightly-2026-04-14 fmt --all 2>&1\", max_retries=0]\n\n start -> toolchain\n toolchain -> preflight_compile [condition=\"outcome=succeeded\"]\n toolchain -> exit\n preflight_compile -> preflight_lint [condition=\"outcome=succeeded\"]\n preflight_compile -> exit\n preflight_lint -> implement [condition=\"outcome=succeeded\"]\n preflight_lint -> fix_lints\n fix_lints -> preflight_lint\n implement -> simplify_opus -> simplify_gpt -> verify\n verify -> fmt [condition=\"outcome=succeeded\"]\n verify -> fixup\n fixup -> verify\n fmt -> exit\n}\n", + "workflow_slug": "implement-plan", + "source_directory": "/Users/bhelmkamp/p/fabro-sh/fabro", + "provenance": { + "server": { + "version": "0.240.0-nightly.1" + }, + "client": { + "user_agent": "fabro-cli/0.240.0-nightly.1", + "name": "fabro-cli", + "version": "0.240.0-nightly.1" + }, + "subject": { + "kind": "user", + "identity": { + "issuer": "https://github.com", + "subject": "19" + }, + "login": "brynary", + "auth_method": "github" + } + }, + "manifest_blob": "f239f066a7d61f3ffd5ab7d32d0220082ae818d5b4cd01d15f216890f6962c7f", + "definition_blob": "5ace3f97710c4df8f504c4d2063948448720ee6c698e9cc4fd1d2fc99249e085", + "git": { + "origin_url": "https://github.com/fabro-sh/fabro", + "branch": "main", + "sha": "06ee2fea39a9e367134990d7ca88d2b6cf9f73ed", + "dirty": "dirty", + "push_outcome": { + "type": "succeeded", + "remote": "origin", + "branch": "main" + } + } + }, + "web_url": "http://127.0.0.1:32276/runs/01KS69T5X6B5RQ87DGT5BWS1JH", + "start": null, + "status": { + "kind": "starting" + }, + "status_updated_at": "2026-05-21T22:17:04.223070Z", + "last_event_at": "2026-05-21T22:17:21.097412Z", + "pending_control": null, + "checkpoints": [], + "conclusion": null, + "sandbox": { + "provider": "daytona", + "image": "buildpack-deps:noble", + "snapshot": "fabro-v11", + "runtime": { + "id": "fabro-01KS69T5X6B5RQ87DGT5BWS1JH", + "working_directory": "/home/daytona/workspace/fabro", + "repo_cloned": true, + "clone_origin_url": "https://github.com/fabro-sh/fabro", + "clone_branch": "main", + "workspace_root": "/home/daytona/workspace", + "repos_root": "/home/daytona/repos", + "primary_repo_path": "/home/daytona/repos/fabro-sh/fabro", + "primary_repo_link": "/home/daytona/workspace/fabro" + } + }, + "pull_request": null, + "superseded_by": null, + "pending_interviews": {}, + "stages": {} +} \ No newline at end of file