diff --git a/backend/alembic/versions/6492ec8d2d5b_add_fix_outcome_tracking_columns_to_.py b/backend/alembic/versions/6492ec8d2d5b_add_fix_outcome_tracking_columns_to_.py new file mode 100644 index 00000000..a544e450 --- /dev/null +++ b/backend/alembic/versions/6492ec8d2d5b_add_fix_outcome_tracking_columns_to_.py @@ -0,0 +1,74 @@ +"""add fix outcome tracking columns to session_suggested_fixes + +Adds: status, applied_at, verified_at, partial_notes, failure_reason, +ai_outcome_proposal. + +status is the outcome dimension (did the fix work?), orthogonal to the +existing user_decision column (which script-path the engineer took). + +Revision ID: 6492ec8d2d5b +Revises: f07010f17b01 +Create Date: 2026-04-23 18:32:38.609719 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = '6492ec8d2d5b' +down_revision: Union[str, None] = 'f07010f17b01' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "session_suggested_fixes", + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'proposed'")), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("partial_notes", sa.Text(), nullable=True), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("failure_reason", sa.Text(), nullable=True), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True), + ) + # Backfill before constraint creation so dismissed rows satisfy the new CHECK. + op.execute( + "UPDATE session_suggested_fixes " + "SET status = 'dismissed' " + "WHERE user_decision = 'dismissed'" + ) + op.create_check_constraint( + "ck_session_suggested_fixes_status", + "session_suggested_fixes", + "status IN ('proposed', 'applied_success', 'applied_failed', 'applied_partial', 'dismissed')", + ) + op.alter_column("session_suggested_fixes", "status", server_default=None) + + +def downgrade() -> None: + op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check") + op.drop_column("session_suggested_fixes", "ai_outcome_proposal") + op.drop_column("session_suggested_fixes", "failure_reason") + op.drop_column("session_suggested_fixes", "partial_notes") + op.drop_column("session_suggested_fixes", "verified_at") + op.drop_column("session_suggested_fixes", "applied_at") + op.drop_column("session_suggested_fixes", "status") diff --git a/backend/app/models/session_suggested_fix.py b/backend/app/models/session_suggested_fix.py index 2be69f47..9cedf1b0 100644 --- a/backend/app/models/session_suggested_fix.py +++ b/backend/app/models/session_suggested_fix.py @@ -35,6 +35,11 @@ class SessionSuggestedFix(Base): "'one_off', 'draft_template', 'build_template', 'dismissed')", name="ck_session_suggested_fixes_user_decision", ), + CheckConstraint( + "status IN ('proposed', 'applied_success', 'applied_failed', " + "'applied_partial', 'dismissed')", + name="ck_session_suggested_fixes_status", + ), ) id: Mapped[uuid.UUID] = mapped_column( @@ -65,6 +70,21 @@ class SessionSuggestedFix(Base): JSONB, nullable=True ) user_decision: Mapped[str | None] = mapped_column(String(32), nullable=True) + # Outcome dimension — did the fix work? Orthogonal to user_decision. + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="proposed" + ) + applied_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + verified_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column( + JSONB, nullable=True + ) # Set when a newer suggested fix supersedes this one. superseded_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True diff --git a/docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md b/docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md new file mode 100644 index 00000000..d6451cf0 --- /dev/null +++ b/docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md @@ -0,0 +1,1671 @@ +# FlowPilot Phase 8 — Fix Outcome Banner Implementation Plan + +> **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. + +**Goal:** Replace the task-lane Suggested Fix card with a chat-composer-anchored **Proposal Banner** that owns the full lifecycle of a proposed fix (Proposed → Verifying → Success / Failed / Partial / Dismissed), with explicit outcome tracking, AI chat-inferred outcomes via a new `[FIX_OUTCOME]` marker, and implicit signals wired to the Resolve / Escalate actions. + +**Architecture:** +- Backend extends `session_suggested_fixes` with outcome columns (`status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`) and adds a PATCH `/outcome` endpoint. `unified_chat_service` learns a new `[FIX_OUTCOME]` marker that writes outcome proposals (not terminal — engineer confirms). FLOWPILOT_SYSTEM_PROMPT gains marker instructions in placeholder form (anti-parrot compliant). +- Frontend replaces `SuggestedFix.tsx` (task-lane card) with a new `ProposalBanner.tsx` docked above the chat composer. The banner is a state-driven component (`proposed | verifying | partial | ai_confirming | dismissed`) with a sibling `EscalateInterceptDialog` popover. AssistantChatPage orchestrates state transitions and wires the implicit signals (Resolve auto-success, Escalate intercept, post-apply-message nudge). + +**Tech Stack:** Python 3.11 + FastAPI + SQLAlchemy 2.0 async + Alembic + Pydantic v2 (backend); React 19 + Vite + TypeScript + Tailwind v4 (frontend). pytest for backend tests; frontend verified via `tsc -b` + browser smoke-test (no unit-test harness for new components — the codebase has no component test pattern per the handoff note on Phase 7 visual verification). + +**Design reference:** [mockups/06-slide-up-banner.html](mockups/06-slide-up-banner.html) (banner look & feel), [mockups/07-verify-states.html](mockups/07-verify-states.html) (Verifying / Partial / AI-inferred / Nudge / Escalate-intercept states). + +--- + +## File Structure + +### Backend — new +- `backend/alembic/versions/_fix_outcome_tracking.py` — migration +- `backend/tests/test_fix_outcome_endpoint.py` — endpoint integration test +- `backend/tests/test_fix_outcome_marker.py` — marker-parser test + +### Backend — modified +- `backend/app/models/session_suggested_fix.py` — add outcome columns +- `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixOutcomeRequest`, extend response with outcome fields +- `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH `/outcome` endpoint +- `backend/app/services/unified_chat_service.py` — add `[FIX_OUTCOME]` parser + persist step +- `backend/app/services/flowpilot_engine.py` — add `[FIX_OUTCOME]` instructions to `FLOWPILOT_SYSTEM_PROMPT` +- `backend/tests/test_prompt_anti_parrot.py` — extend known-leaked-token list if needed + +### Frontend — new +- `frontend/src/components/pilot/ProposalBanner.tsx` — state-driven banner, all five rendering states +- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — popover dialog +- `frontend/src/hooks/useFixOutcome.ts` — hook wrapping outcome state + patch call + +### Frontend — modified +- `frontend/src/api/sessionSuggestedFixes.ts` — add `patchOutcome` method + extend `SessionSuggestedFix` type with outcome fields + export `FixStatus` type +- `frontend/src/pages/AssistantChatPage.tsx` — mount `ProposalBanner` above composer, wire state transitions, intercept Escalate, auto-mark success on Resolve-while-verifying, remove `suggestedFixSlot` prop usage +- `frontend/src/components/pilot/TaskLane.tsx` — stop passing `suggestedFixSlot` (leave prop in place for other callers, pass null) + +### Frontend — deleted +- `frontend/src/components/pilot/sections/SuggestedFix.tsx` — superseded by the banner; delete after integration is verified + +--- + +## Task 1: DB migration + model extension for outcome tracking + +**Files:** +- Create: `backend/alembic/versions/_fix_outcome_tracking.py` +- Modify: `backend/app/models/session_suggested_fix.py` + +- [ ] **Step 1: Generate the migration file** + +From `backend/` with venv active: + +```bash +alembic revision -m "add fix outcome tracking columns to session_suggested_fixes" +``` + +Alembic prints the new file path. Do NOT pass `--rev-id` (Lesson: always let alembic generate the hex hash). + +- [ ] **Step 2: Write the migration body** + +Replace the generated file contents with (keep the `revision`/`down_revision` lines alembic wrote): + +```python +"""add fix outcome tracking columns to session_suggested_fixes + +Adds: status, applied_at, verified_at, partial_notes, failure_reason, +ai_outcome_proposal. +status is the outcome dimension (did the fix work?), orthogonal to the +existing user_decision column (which script-path the engineer took). +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers — leave whatever alembic generated in place +revision = "" +down_revision = "" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "session_suggested_fixes", + sa.Column( + "status", + sa.String(length=20), + nullable=False, + server_default=sa.text("'proposed'"), + ), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("partial_notes", sa.Text(), nullable=True), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("failure_reason", sa.Text(), nullable=True), + ) + op.add_column( + "session_suggested_fixes", + sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True), + ) + # Backfill before constraint creation so dismissed rows satisfy the new CHECK. + op.execute( + "UPDATE session_suggested_fixes " + "SET status = 'dismissed' " + "WHERE user_decision = 'dismissed'" + ) + op.create_check_constraint( + "ck_session_suggested_fixes_status", + "session_suggested_fixes", + "status IN ('proposed', 'applied_success', 'applied_failed', " + "'applied_partial', 'dismissed')", + ) + # Drop the server_default — application code owns defaults from here on + # (matches the project's general pattern of keeping defaults in models). + op.alter_column("session_suggested_fixes", "status", server_default=None) + + +def downgrade() -> None: + op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check") + op.drop_column("session_suggested_fixes", "ai_outcome_proposal") + op.drop_column("session_suggested_fixes", "failure_reason") + op.drop_column("session_suggested_fixes", "partial_notes") + op.drop_column("session_suggested_fixes", "verified_at") + op.drop_column("session_suggested_fixes", "applied_at") + op.drop_column("session_suggested_fixes", "status") +``` + +- [ ] **Step 3: Update the SQLAlchemy model** + +Edit `backend/app/models/session_suggested_fix.py`. Inside the `__table_args__` tuple, append after the existing `user_decision` check: + +```python + CheckConstraint( + "status IN ('proposed', 'applied_success', 'applied_failed', " + "'applied_partial', 'dismissed')", + name="ck_session_suggested_fixes_status", + ), +``` + +Inside the class body, add these columns after `user_decision`: + +```python + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="proposed" + ) + applied_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + verified_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column( + JSONB, nullable=True + ) +``` + +- [ ] **Step 4: Apply the migration locally** + +```bash +cd backend && alembic upgrade head +``` + +Expected: no errors; `\d session_suggested_fixes` in psql shows the five new columns and the new check constraint. + +Verify via: + +```bash +docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow \ + -c "\d session_suggested_fixes" | grep -E "status|applied_at|verified_at|partial_notes|failure_reason" +``` + +Expected: all five column names print. + +- [ ] **Step 5: Commit** + +```bash +git add backend/alembic/versions/ backend/app/models/session_suggested_fix.py +git commit -m "feat(pilot): add outcome tracking columns to session_suggested_fixes" +``` + +--- + +## Task 2: Pydantic schemas for outcome + +**Files:** +- Modify: `backend/app/schemas/session_suggested_fix.py` + +- [ ] **Step 1: Extend the response schema and add the request schema** + +At the top of `backend/app/schemas/session_suggested_fix.py`, after the existing `UserDecision` literal, add: + +```python +FixStatus = Literal[ + "proposed", + "applied_success", + "applied_failed", + "applied_partial", + "dismissed", +] +``` + +Extend `SessionSuggestedFixResponse` with the new fields (add after `user_decision`): + +```python + status: FixStatus + applied_at: datetime | None + verified_at: datetime | None + partial_notes: str | None + failure_reason: str | None +``` + +After `SessionSuggestedFixDecisionResponse`, add: + +```python +class SessionSuggestedFixOutcomeRequest(BaseModel): + """Engineer-reported outcome of applying a suggested fix. + + Writes to session_suggested_fixes.status and companion columns. This is + orthogonal to `user_decision` (which records which script-path the + engineer took); outcome captures whether the fix actually worked. + + Allowed transitions: + - from `proposed`: any of applied_success | applied_failed | applied_partial | dismissed + - from `applied_partial`: applied_success | applied_failed (partial is not terminal) + - from any terminal outcome: no change (server returns 409) + """ + outcome: Literal[ + "applied_success", "applied_failed", "applied_partial", "dismissed" + ] + # Required for applied_partial, optional for applied_failed, ignored otherwise. + notes: str | None = Field(None, max_length=500) +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/schemas/session_suggested_fix.py +git commit -m "feat(pilot): pydantic schemas for fix outcome patch" +``` + +--- + +## Task 3: PATCH /outcome endpoint + test + +**Files:** +- Modify: `backend/app/api/endpoints/session_suggested_fixes.py` +- Create: `backend/tests/test_fix_outcome_endpoint.py` + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_fix_outcome_endpoint.py`: + +```python +"""Integration tests for the fix outcome endpoint. + +These tests rely on the `engineer_client` + `seed_ai_session_with_fix` +fixtures from `conftest.py` (existing pattern used by the decision-endpoint +tests). +""" +from __future__ import annotations + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_patch_outcome_marks_success( + engineer_client: AsyncClient, seed_ai_session_with_fix +): + session_id, fix_id = seed_ai_session_with_fix + + r = await engineer_client.patch( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", + json={"outcome": "applied_success"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "applied_success" + assert body["verified_at"] is not None + + +@pytest.mark.asyncio +async def test_patch_outcome_partial_requires_notes( + engineer_client: AsyncClient, seed_ai_session_with_fix +): + session_id, fix_id = seed_ai_session_with_fix + + r = await engineer_client.patch( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", + json={"outcome": "applied_partial"}, + ) + assert r.status_code == 400 + assert "notes" in r.text.lower() + + +@pytest.mark.asyncio +async def test_partial_to_success_allowed( + engineer_client: AsyncClient, seed_ai_session_with_fix +): + session_id, fix_id = seed_ai_session_with_fix + + r1 = await engineer_client.patch( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", + json={"outcome": "applied_partial", "notes": "ran cred clear only"}, + ) + assert r1.status_code == 200 + + r2 = await engineer_client.patch( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", + json={"outcome": "applied_success"}, + ) + assert r2.status_code == 200 + assert r2.json()["status"] == "applied_success" + + +@pytest.mark.asyncio +async def test_terminal_outcome_is_locked( + engineer_client: AsyncClient, seed_ai_session_with_fix +): + session_id, fix_id = seed_ai_session_with_fix + + r1 = await engineer_client.patch( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", + json={"outcome": "applied_failed", "notes": "no change"}, + ) + assert r1.status_code == 200 + + r2 = await engineer_client.patch( + f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", + json={"outcome": "applied_success"}, + ) + assert r2.status_code == 409 +``` + +- [ ] **Step 2: Run the test — confirm it fails** + +```bash +cd backend && pytest tests/test_fix_outcome_endpoint.py -v --override-ini="addopts=" +``` + +Expected: four failures — endpoint doesn't exist yet (404 on PATCH). + +- [ ] **Step 3: Add the endpoint** + +Open `backend/app/api/endpoints/session_suggested_fixes.py`. Add near the other handlers (after the `decision` endpoint): + +```python +@router.patch( + "/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome", + response_model=SessionSuggestedFixResponse, + tags=["flowpilot"], +) +async def patch_suggested_fix_outcome( + session_id: UUID, + fix_id: UUID, + body: SessionSuggestedFixOutcomeRequest, + current_user: User = Depends(require_engineer_or_admin), + db: AsyncSession = Depends(get_db), +) -> SessionSuggestedFix: + """Record the engineer's outcome for an applied fix. + + See SessionSuggestedFixOutcomeRequest for the transition rules. + """ + now = datetime.now(timezone.utc) + + fix = await db.scalar( + select(SessionSuggestedFix).where( + SessionSuggestedFix.id == fix_id, + SessionSuggestedFix.session_id == session_id, + ) + ) + if fix is None: + raise HTTPException(status_code=404, detail="Suggested fix not found") + + # Partial requires a note; without one we can't tell anyone (including + # the AI on the next turn) what actually got done. + if body.outcome == "applied_partial" and not (body.notes and body.notes.strip()): + raise HTTPException( + status_code=400, + detail="notes are required when outcome is applied_partial", + ) + + TERMINAL = {"applied_success", "applied_failed", "dismissed"} + if fix.status in TERMINAL: + raise HTTPException( + status_code=409, + detail=f"Fix is already in terminal status {fix.status!r}", + ) + + fix.status = body.outcome + if body.outcome == "applied_partial": + fix.partial_notes = (body.notes or "").strip() or None + # Partial is a parked state — no verified_at yet. + elif body.outcome == "applied_failed": + fix.failure_reason = (body.notes or "").strip() or None + fix.verified_at = now + elif body.outcome == "applied_success": + fix.verified_at = now + # applied_at is stamped by whoever calls this in the apply flow; if it's + # still null at outcome-set time, set it to now (handles the case where + # the AI emits [FIX_OUTCOME] before the engineer clicks Apply explicitly). + if fix.applied_at is None and body.outcome != "dismissed": + fix.applied_at = now + + await db.commit() + await db.refresh(fix) + return fix +``` + +Add missing imports at the top of the file if not already present: + +```python +from datetime import datetime, timezone +from app.schemas.session_suggested_fix import SessionSuggestedFixOutcomeRequest +``` + +- [ ] **Step 4: Run the tests — confirm they pass** + +```bash +cd backend && pytest tests/test_fix_outcome_endpoint.py -v --override-ini="addopts=" +``` + +Expected: 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/endpoints/session_suggested_fixes.py backend/tests/test_fix_outcome_endpoint.py +git commit -m "feat(pilot): PATCH /suggested-fixes/:id/outcome endpoint + tests" +``` + +--- + +## Task 4: `[FIX_OUTCOME]` marker parser + test + +**Files:** +- Modify: `backend/app/services/unified_chat_service.py` +- Create: `backend/tests/test_fix_outcome_marker.py` + +- [ ] **Step 1: Write the failing test** + +Create `backend/tests/test_fix_outcome_marker.py`: + +```python +"""Unit tests for the [FIX_OUTCOME] marker parser.""" +from __future__ import annotations + +from app.services.unified_chat_service import _parse_fix_outcome_marker + + +def test_parses_success_outcome(): + ai = ( + "Great news — that confirms the root cause.\n\n" + "[FIX_OUTCOME]\n" + '{"fix_id":"11111111-1111-1111-1111-111111111111",' + '"outcome":"success","reason":"user said the fix worked"}\n' + "[/FIX_OUTCOME]\n" + ) + cleaned, parsed = _parse_fix_outcome_marker(ai) + assert "[FIX_OUTCOME]" not in cleaned + assert "confirms the root cause" in cleaned + assert parsed == { + "fix_id": "11111111-1111-1111-1111-111111111111", + "outcome": "success", + "reason": "user said the fix worked", + } + + +def test_parses_failure_outcome(): + ai = ( + "[FIX_OUTCOME]\n" + '{"fix_id":"22222222-2222-2222-2222-222222222222",' + '"outcome":"failure","reason":"user reports still broken"}\n' + "[/FIX_OUTCOME]" + ) + _, parsed = _parse_fix_outcome_marker(ai) + assert parsed["outcome"] == "failure" + + +def test_missing_marker_returns_none(): + ai = "no marker here" + cleaned, parsed = _parse_fix_outcome_marker(ai) + assert cleaned == ai + assert parsed is None + + +def test_invalid_json_is_dropped(): + ai = "[FIX_OUTCOME]\nnot-json\n[/FIX_OUTCOME]" + cleaned, parsed = _parse_fix_outcome_marker(ai) + assert "[FIX_OUTCOME]" not in cleaned + assert parsed is None + + +def test_unknown_outcome_rejected(): + ai = ( + "[FIX_OUTCOME]\n" + '{"fix_id":"33333333-3333-3333-3333-333333333333",' + '"outcome":"maybe","reason":"x"}\n' + "[/FIX_OUTCOME]" + ) + _, parsed = _parse_fix_outcome_marker(ai) + assert parsed is None +``` + +- [ ] **Step 2: Run the test — confirm it fails** + +```bash +cd backend && pytest tests/test_fix_outcome_marker.py -v --override-ini="addopts=" +``` + +Expected: `ImportError: cannot import name '_parse_fix_outcome_marker'` — parser doesn't exist yet. + +- [ ] **Step 3: Implement the parser** + +Open `backend/app/services/unified_chat_service.py`. Right after `_parse_suggest_fix_marker`, add: + +```python +def _parse_fix_outcome_marker( + ai_content: str, +) -> tuple[str, dict[str, Any] | None]: + """Extract a single [FIX_OUTCOME]...[/FIX_OUTCOME] JSON block. + + Block shape: + {"fix_id": "", "outcome": "success"|"failure"|"partial", + "reason": ""} + + Emitted by the AI when the engineer clearly indicates in chat that a + prior suggested fix worked, didn't work, or was partially applied. + The marker PROPOSES an outcome — the engineer confirms via the UI. + Only the last block in a response is honored. + """ + blocks = list(re.finditer( + r"\[FIX_OUTCOME\]\s*([\s\S]*?)\s*\[/FIX_OUTCOME\]", ai_content, + )) + if not blocks: + return ai_content, None + + last = blocks[-1] + raw = last.group(1).strip() + if raw.startswith("```"): + raw = re.sub(r"^```(?:json)?\s*", "", raw) + raw = re.sub(r"\s*```$", "", raw) + + cleaned = re.sub( + r"\[FIX_OUTCOME\]\s*[\s\S]*?\s*\[/FIX_OUTCOME\]", "", ai_content, + ).strip() + + try: + data = json.loads(raw) + except (json.JSONDecodeError, ValueError) as e: + logger.warning("Failed to parse [FIX_OUTCOME] block: %s", e) + return cleaned, None + + if not isinstance(data, dict): + return cleaned, None + + fix_id = str(data.get("fix_id") or "").strip() + outcome = str(data.get("outcome") or "").strip().lower() + reason = str(data.get("reason") or "").strip() + + if not fix_id or outcome not in {"success", "failure", "partial"}: + logger.warning("[FIX_OUTCOME] missing/invalid fields, dropping") + return cleaned, None + + return cleaned, {"fix_id": fix_id, "outcome": outcome, "reason": reason} +``` + +- [ ] **Step 4: Wire the parser into the AI-response handler** + +In the same file, find the block where `_parse_suggest_fix_marker` is called during AI-response processing. Add a companion call right after it. The exact location is the function that processes the raw AI response before persisting messages — search for `_parse_suggest_fix_marker(` to find it. Inside that function, after the suggest-fix call: + +```python + ai_content, outcome_proposal = _parse_fix_outcome_marker(ai_content) +``` + +Then, after the suggest-fix persistence block, add: + +```python + if outcome_proposal is not None: + # The AI is proposing an outcome; we persist it as an + # ai_outcome_proposal event on the session so the frontend can + # surface the "AI detected outcome — confirm?" banner. The + # session's active fix is not mutated until the engineer confirms. + await _record_ai_outcome_proposal( + db=db, session=session, proposal=outcome_proposal, + ) +``` + +And add the helper near `_persist_suggested_fix`: + +```python +async def _record_ai_outcome_proposal( + *, + db: AsyncSession, + session: AISession, + proposal: dict[str, Any], +) -> None: + """Persist the AI's proposed outcome as a pending event on the active fix. + + We store this on a new JSONB column on the fix (ai_outcome_proposal) + rather than as a separate event table — one active proposal at a time, + overwritten on each new [FIX_OUTCOME]. Frontend polls the active fix + and renders the AI-confirming banner state when this is non-null. + """ + from uuid import UUID as _UUID + try: + fix_id = _UUID(proposal["fix_id"]) + except (ValueError, KeyError): + logger.warning("[FIX_OUTCOME] invalid fix_id, dropping") + return + + await db.execute( + update(SessionSuggestedFix) + .where( + SessionSuggestedFix.id == fix_id, + SessionSuggestedFix.session_id == session.id, + ) + .values(ai_outcome_proposal=proposal) + ) +``` + +- [ ] **Step 5: Run the marker tests — confirm they pass** + +```bash +cd backend && pytest tests/test_fix_outcome_marker.py tests/test_fix_outcome_endpoint.py -v --override-ini="addopts=" +``` + +Expected: 9 passed (5 marker + 4 endpoint). + +- [ ] **Step 6: Commit** + +```bash +git add backend/ +git commit -m "feat(pilot): [FIX_OUTCOME] marker parser + ai_outcome_proposal column" +``` + +--- + +## Task 5: System prompt update + anti-parrot guardrail + +**Files:** +- Modify: `backend/app/services/flowpilot_engine.py` (or wherever `FLOWPILOT_SYSTEM_PROMPT` lives — confirm at start) +- Modify: `backend/tests/test_prompt_anti_parrot.py` + +- [ ] **Step 1: Locate the system prompt** + +```bash +grep -rln "FLOWPILOT_SYSTEM_PROMPT\s*=" /config/workspace/resolutionflow/backend/app/ +``` + +Open whichever file owns the constant. + +- [ ] **Step 2: Append the `[FIX_OUTCOME]` instructions to the prompt** + +Inside the prompt string, in the markers section, add a new block alongside the existing `[SUGGEST_FIX]` instructions. **Use placeholder syntax exclusively — never literal values** (anti-parrot lesson): + +``` +## Reporting fix outcome with [FIX_OUTCOME] + +When the engineer clearly indicates in chat that a previously proposed fix +worked, didn't work, or was partially applied, emit a [FIX_OUTCOME] marker +on its own lines. This surfaces a "confirm outcome?" banner in the UI — it +does NOT mark the fix resolved on its own. + +Emit [FIX_OUTCOME] when: +- the engineer states the user's problem is resolved after applying the fix + (e.g. affirmative resolution language → outcome="success") +- the engineer states the issue persists after applying the fix + (→ outcome="failure") +- the engineer describes applying only part of the fix + (→ outcome="partial") + +Do NOT emit [FIX_OUTCOME] when: +- the engineer is still verifying (user rebooting, testing, etc.) +- the outcome is ambiguous or inferred rather than stated +- no [SUGGEST_FIX] has been emitted this session + +Format (one block, on its own lines, placeholders only): + +[FIX_OUTCOME] +{"fix_id": "", + "outcome": "", + "reason": ""} +[/FIX_OUTCOME] +``` + +- [ ] **Step 3: Update the anti-parrot test** + +Open `backend/tests/test_prompt_anti_parrot.py`. The existing test scans for literal tokens. If `[FIX_OUTCOME]` introduces any new tokens worth guarding (none expected — all placeholders are `<…>` style), add them to the leaked-token list. If nothing new, move on — the existing scanner will catch any literal JSON value that sneaks in. + +- [ ] **Step 4: Run the anti-parrot test** + +```bash +cd backend && pytest tests/test_prompt_anti_parrot.py -v --override-ini="addopts=" +``` + +Expected: pass. If it fails with a literal-value complaint, re-read the prompt and swap any concrete fix_id / outcome / reason you accidentally wrote into `` form. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/flowpilot_engine.py backend/tests/test_prompt_anti_parrot.py +git commit -m "feat(pilot): [FIX_OUTCOME] system prompt instructions" +``` + +--- + +## Task 6: Frontend types + API client + +**Files:** +- Modify: `frontend/src/api/sessionSuggestedFixes.ts` + +- [ ] **Step 1: Extend the types and add the patch method** + +At the top of `frontend/src/api/sessionSuggestedFixes.ts`, after `UserDecision`, add: + +```ts +export type FixStatus = + | 'proposed' + | 'applied_success' + | 'applied_failed' + | 'applied_partial' + | 'dismissed' + +export type FixOutcome = + | 'applied_success' + | 'applied_failed' + | 'applied_partial' + | 'dismissed' + +export interface AIOutcomeProposal { + fix_id: string + outcome: 'success' | 'failure' | 'partial' + reason: string +} +``` + +Extend `SessionSuggestedFix` to include the new fields (add after `user_decision`): + +```ts + status: FixStatus + applied_at: string | null + verified_at: string | null + partial_notes: string | null + failure_reason: string | null + ai_outcome_proposal: AIOutcomeProposal | null +``` + +Add a new method on `sessionSuggestedFixesApi` (after `recordDecision`): + +```ts + /** + * Record the outcome of applying a suggested fix. Transitions: + * - from 'proposed': any non-proposed outcome + * - from 'applied_partial': applied_success | applied_failed + * - terminal statuses are locked (server returns 409) + */ + async patchOutcome( + sessionId: string, + fixId: string, + outcome: FixOutcome, + notes?: string, + ): Promise { + const r = await apiClient.patch( + `/ai-sessions/${sessionId}/suggested-fixes/${fixId}/outcome`, + { outcome, notes }, + ) + return r.data + }, +``` + +- [ ] **Step 2: Verify types compile** + +```bash +cd frontend && npx tsc -b +``` + +Expected: clean build, no errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/api/sessionSuggestedFixes.ts +git commit -m "feat(pilot): frontend fix-outcome types + patch API" +``` + +--- + +## Task 7: ProposalBanner component — core + Proposed state + +**Files:** +- Create: `frontend/src/components/pilot/ProposalBanner.tsx` + +- [ ] **Step 1: Create the component scaffold** + +Create `frontend/src/components/pilot/ProposalBanner.tsx`: + +```tsx +/** + * ProposalBanner — chat-composer-anchored banner that carries the lifecycle + * of a suggested fix from Proposed → Verifying → terminal outcome. + * + * Replaces the task-lane SuggestedFix card (Phase 8). The banner renders + * above the chat composer in AssistantChatPage. Parent owns the fix record + * and the outcome mutations; this component renders + dispatches callbacks. + * + * Visual reference: docs/FlowAssist_Migration/mockups/06-slide-up-banner.html + * + 07-verify-states.html. + */ +import { useState } from 'react' +import { Sparkles, X, Check, Info, ChevronDown, MoreHorizontal } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { + SessionSuggestedFix, + FixOutcome, +} from '@/api/sessionSuggestedFixes' + +export type BannerMode = + | 'proposed' // AI just proposed; engineer hasn't applied yet + | 'verifying' // Engineer clicked Apply; awaiting outcome + | 'partial' // Applied partially; awaiting finish or terminal outcome + | 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms + | 'nudge' // Compact nudge shown after N post-apply messages + +export interface ProposalBannerProps { + fix: SessionSuggestedFix + mode: BannerMode + onApply: () => void + onDismiss: () => void + onOutcome: (outcome: FixOutcome, notes?: string) => void + onAcceptAIProposal: () => void + onRejectAIProposal: () => void + /** Collapsed variant shown as a thin single-line strip. */ + collapsed?: boolean + onToggleCollapsed?: () => void +} + +export function ProposalBanner(props: ProposalBannerProps) { + if (props.collapsed) return + switch (props.mode) { + case 'proposed': return + case 'verifying': return + case 'partial': return + case 'ai_confirming': return + case 'nudge': return + } +} + +function ProposedBanner({ fix, onApply, onDismiss }: ProposalBannerProps) { + return ( +
+
+
+
+ +
+
+
+ Suggested Fix + + {fix.confidence_pct}% confidence + +
+
+ {fix.title} +
+
+ {fix.description} +
+ {fix.script_template_id && ( +
+ + Matches an existing Script Library template — one-click apply +
+ )} +
+
+ + + +
+
+
+ ) +} + +// Placeholder renderers — filled in Task 8 / 9. +function VerifyingBanner(_: ProposalBannerProps) { return null } +function PartialBanner(_: ProposalBannerProps) { return null } +function AIConfirmingBanner(_: ProposalBannerProps) { return null } +function NudgeBanner(_: ProposalBannerProps) { return null } +function CollapsedBanner(_: ProposalBannerProps) { return null } + +export default ProposalBanner +``` + +- [ ] **Step 2: Add the slide-up animation** + +Open `frontend/src/index.css`. Add near the existing animations: + +```css +@keyframes slide-up { + from { transform: translateY(14px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +.animate-slide-up { animation: slide-up 320ms cubic-bezier(.22,.9,.28,1) both; } +``` + +- [ ] **Step 3: Compile + visual smoke test** + +```bash +cd frontend && npx tsc -b +``` + +Expected: clean. + +To visually check, mount it ad-hoc by editing `AssistantChatPage.tsx` temporarily to render ` {}} onDismiss={() => {}} onOutcome={() => {}} onAcceptAIProposal={() => {}} onRejectAIProposal={() => {}} />` above the composer. Revert the edit before commit. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/pilot/ProposalBanner.tsx frontend/src/index.css +git commit -m "feat(pilot): ProposalBanner scaffold + Proposed state" +``` + +--- + +## Task 8: ProposalBanner — Verifying + Partial states + +**Files:** +- Modify: `frontend/src/components/pilot/ProposalBanner.tsx` + +- [ ] **Step 1: Implement VerifyingBanner** + +Replace the `function VerifyingBanner` placeholder with: + +```tsx +function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) { + const [showOverflow, setShowOverflow] = useState(false) + const appliedLabel = fix.applied_at + ? `Applied ${formatRelativeMinutes(fix.applied_at)}` + : 'Applied' + + return ( +
+
+
+
+ + + + +
+
+
+ Verifying + + {appliedLabel} + +
+
+ Did "{fix.title}" work? +
+
+ Mark the outcome so the AI can either close the session with this as the resolution, or propose something else. +
+
+
+ + {showOverflow && ( +
+ +
+ )} + + +
+
+
+ ) +} + +function formatRelativeMinutes(iso: string): string { + const then = new Date(iso).getTime() + const mins = Math.max(0, Math.round((Date.now() - then) / 60000)) + if (mins === 0) return 'just now' + if (mins === 1) return '1m ago' + return `${mins}m ago` +} +``` + +Add the pulse animation to `frontend/src/index.css`: + +```css +@keyframes pulse-amber { + 0% { box-shadow: 0 0 0 0 rgba(251,191,36,0.45); } + 70% { box-shadow: 0 0 0 10px rgba(251,191,36,0); } + 100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); } +} +.animate-pulse-amber { animation: pulse-amber 1.6s infinite; } +``` + +- [ ] **Step 2: Implement PartialBanner** + +Replace the `function PartialBanner` placeholder with: + +```tsx +function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) { + return ( +
+
+
+
+ +
+
+
+ Partially applied + + Parked + +
+
+ {fix.title} +
+ {fix.partial_notes && ( +
+ Note + {fix.partial_notes} +
+ )} +
+
+ + + +
+
+
+ ) +} +``` + +- [ ] **Step 3: Verify types compile** + +```bash +cd frontend && npx tsc -b +``` + +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/pilot/ProposalBanner.tsx frontend/src/index.css +git commit -m "feat(pilot): banner Verifying + Partial states" +``` + +--- + +## Task 9: ProposalBanner — AIConfirming + Nudge + Collapsed states + +**Files:** +- Modify: `frontend/src/components/pilot/ProposalBanner.tsx` + +- [ ] **Step 1: Implement AIConfirmingBanner** + +Replace the placeholder: + +```tsx +function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) { + const proposal = fix.ai_outcome_proposal + if (!proposal) return null + const isSuccess = proposal.outcome === 'success' + + return ( +
+
+
+
+ +
+
+
+ AI detected outcome + + {isSuccess ? 'Success' : proposal.outcome === 'failure' ? 'Failure' : 'Partial'} + +
+
+ AI thinks the fix {isSuccess ? 'resolved the issue' : proposal.outcome === 'failure' ? 'didn\'t work' : 'was partially applied'} — confirm? +
+
+ {proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'} +
+
+
+ + +
+
+
+ ) +} +``` + +- [ ] **Step 2: Implement NudgeBanner** + +```tsx +function NudgeBanner({ fix, onOutcome }: ProposalBannerProps) { + return ( +
+
+ + + + + Did "{fix.title}" work? + + + + +
+ ) +} +``` + +- [ ] **Step 3: Implement CollapsedBanner** + +```tsx +function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) { + return ( + + ) +} +``` + +- [ ] **Step 4: Verify Tailwind classes resolve + compile** + +```bash +cd frontend && npx tsc -b && npm run build +``` + +Expected: clean — `npm run build` is stricter than `tsc --noEmit` per Lesson. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/pilot/ProposalBanner.tsx +git commit -m "feat(pilot): banner AI-confirming, Nudge, Collapsed states" +``` + +--- + +## Task 10: EscalateInterceptDialog + +**Files:** +- Create: `frontend/src/components/pilot/EscalateInterceptDialog.tsx` + +- [ ] **Step 1: Create the component** + +```tsx +/** + * Popover dialog that intercepts Escalate when a fix is in verifying/partial + * status. Captures outcome before the escalation so the narrative is honest + * for whoever picks up the ticket. + */ +import { X, AlertCircle, Check } from 'lucide-react' +import type { FixOutcome } from '@/api/sessionSuggestedFixes' + +export interface EscalateInterceptDialogProps { + fixTitle: string + onChoose: ( + outcome: FixOutcome | 'never_applied', + ) => void + onClose: () => void +} + +export function EscalateInterceptDialog({ + fixTitle, onChoose, onClose, +}: EscalateInterceptDialogProps) { + return ( + <> +
+
+
+ Before escalating — what happened with the fix? +
+
+ "{fixTitle}" is still in the Verifying state. Tag its outcome so the senior picking this up knows what's been tried. +
+
+ + + +
+
+ + ) +} + +export default EscalateInterceptDialog +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cd frontend && npx tsc -b +``` + +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/pilot/EscalateInterceptDialog.tsx +git commit -m "feat(pilot): EscalateInterceptDialog popover" +``` + +--- + +## Task 11: Integrate banner into AssistantChatPage + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx` +- Modify: `frontend/src/components/pilot/TaskLane.tsx` (pass null for suggestedFixSlot) + +- [ ] **Step 1: Find and remove the existing SuggestedFix card render** + +In `AssistantChatPage.tsx`, search for `(null) + +const bannerMode: BannerMode | null = (() => { + if (!activeFix) return null + if (activeFix.status === 'dismissed') return null + if (activeFix.ai_outcome_proposal) return 'ai_confirming' + if (activeFix.status === 'applied_partial') return 'partial' + if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null + if (activeFix.applied_at) { + if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge' + return 'verifying' + } + return 'proposed' +})() + +const handleApplyFix = useCallback(async () => { + // Existing apply logic — opens Script Generator / NoTemplateDialog. + // After the script successfully runs (or the engineer acknowledges the + // open-script-in-builder handoff), patch status: proposed → verifying via + // the existing "decision" endpoint (which already stamps one_off/draft/build), + // AND patch outcome via the new API to set applied_at. + await sessionSuggestedFixesApi.patchOutcome( + sessionId, activeFix!.id, activeFix!.status as FixOutcome, /* no notes */ + ).catch(() => { /* tolerate — applied_at is stamped server-side on next outcome call */ }) + setPostApplyMsgCount(0) + setNudgeSilenced(false) + /* existing apply flow continues here */ +}, [sessionId, activeFix]) + +const handleSetOutcome = useCallback(async (outcome: FixOutcome, notes?: string) => { + if (!activeFix) return + const updated = await sessionSuggestedFixesApi.patchOutcome( + sessionId, activeFix.id, outcome, notes, + ) + setActiveFix(updated) + if (outcome === 'applied_success') { + // Auto-open ResolutionNotePreview pre-filled with the fix as resolution. + openResolutionNotePreview() + } +}, [sessionId, activeFix, openResolutionNotePreview]) + +const handleAcceptAIProposal = useCallback(async () => { + if (!activeFix?.ai_outcome_proposal) return + const map: Record = { + success: 'applied_success', failure: 'applied_failed', partial: 'applied_partial', + } + const outcome = map[activeFix.ai_outcome_proposal.outcome] + const notes = activeFix.ai_outcome_proposal.reason + await handleSetOutcome( + outcome, + outcome === 'applied_partial' || outcome === 'applied_failed' ? notes : undefined, + ) +}, [activeFix, handleSetOutcome]) + +const handleRejectAIProposal = useCallback(async () => { + if (!activeFix) return + // Clear the proposal without changing status. Backend: PATCH the fix + // with ai_outcome_proposal=null (add a small endpoint or reuse outcome + // with a 'clear_proposal' intent — minimal: clear it server-side when + // the engineer clicks either terminal outcome button). + setActiveFix({ ...activeFix, ai_outcome_proposal: null }) +}, [activeFix]) +``` + +Increment `postApplyMsgCount` inside the existing chat-send handler(s). Grep for `sendChatMessage` to find them, then after each successful engineer-send: + +```tsx +if (activeFix?.applied_at && activeFix.status !== 'applied_success' && activeFix.status !== 'applied_failed') { + setPostApplyMsgCount(c => c + 1) +} +``` + +- [ ] **Step 3: Render the banner above the composer** + +Find the composer JSX (search for `ChatComposer` or the ` setBannerCollapsed(v => !v)} + onApply={handleApplyFix} + onDismiss={() => handleSetOutcome('dismissed')} + onOutcome={handleSetOutcome} + onAcceptAIProposal={handleAcceptAIProposal} + onRejectAIProposal={handleRejectAIProposal} + /> +)} +``` + +- [ ] **Step 4: Wire Escalate intercept** + +Find the Escalate button handler. Wrap it: + +```tsx +const handleEscalateClick = () => { + const inVerifyState = activeFix && ( + activeFix.applied_at && activeFix.status === 'proposed' || + activeFix.status === 'applied_partial' + ) + if (inVerifyState) { + setEscalateIntercept({ fixId: activeFix!.id, fixTitle: activeFix!.title }) + return + } + openEscalatePackagePreview() // existing flow +} + +const handleInterceptChoice = async ( + choice: FixOutcome | 'never_applied' +) => { + setEscalateIntercept(null) + if (choice === 'never_applied') { + await sessionSuggestedFixesApi.patchOutcome(sessionId, escalateIntercept!.fixId, 'dismissed') + } else { + await sessionSuggestedFixesApi.patchOutcome(sessionId, escalateIntercept!.fixId, choice) + } + openEscalatePackagePreview() +} +``` + +Render the dialog somewhere inside the action-bar container: + +```tsx +{escalateIntercept && ( + setEscalateIntercept(null)} + /> +)} +``` + +- [ ] **Step 5: Wire Resolve auto-success** + +Before the existing Resolve-button flow fires: + +```tsx +const handleResolveClick = async () => { + if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') { + // Implicit signal: engineer is resolving while a fix is in Verifying. + await sessionSuggestedFixesApi.patchOutcome(sessionId, activeFix.id, 'applied_success') + } + openResolutionNotePreview() // existing flow +} +``` + +- [ ] **Step 6: Build + browser smoke test** + +```bash +cd frontend && npm run build +``` + +Expected: clean build. + +Start dev stack: +```bash +docker compose -f docker-compose.dev.yml up -d +cd frontend && npm run dev +``` + +Smoke test at with `engineer@resolutionflow.example.com` / `TestPass123!`: +- Start a session, let the AI propose a fix → banner appears above composer (Proposed). +- Click Apply → Verifying state with pulse. +- Click "It worked" → ResolutionNotePreview opens. +- Replay: apply → send 3 chat messages → Nudge strip appears. +- Replay: apply → click Escalate → intercept popover appears. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/pages/AssistantChatPage.tsx frontend/src/components/pilot/TaskLane.tsx +git commit -m "feat(pilot): mount ProposalBanner + wire implicit signals" +``` + +--- + +## Task 12: Final cleanup — remove old SuggestedFix card + +**Files:** +- Delete: `frontend/src/components/pilot/sections/SuggestedFix.tsx` +- Modify: any remaining importers + +- [ ] **Step 1: Find remaining imports** + +```bash +grep -rn "from.*sections/SuggestedFix\|import.*SuggestedFix[^a-z]" /config/workspace/resolutionflow/frontend/src/ +``` + +- [ ] **Step 2: Remove the import sites and delete the file** + +Remove each `import ... SuggestedFix` that remains. Delete: + +```bash +rm frontend/src/components/pilot/sections/SuggestedFix.tsx +``` + +- [ ] **Step 3: Full build check** + +```bash +cd frontend && npm run build +``` + +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "chore(pilot): remove deprecated SuggestedFix task-lane card" +``` + +--- + +## Task 13: Handoff doc update + QA sweep + +**Files:** +- Modify: `docs/handoff/2026-04-22-flowpilot-migration.md` +- Modify: `docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md` (append a Phase 8 section referencing this plan) + +- [ ] **Step 1: Update the handoff doc** + +Change the status line to reflect Phase 8 as shipped, and mark open item #2 as resolved with a pointer to this plan. Leave items #1 and #3 open (they're separate decisions). + +- [ ] **Step 2: QA sweep in browser** + +Run the full happy-path plus these edge cases: +- Proposed → Dismiss → no banner remains, facts untouched. +- Proposed → Apply → Verifying → "Didn't work" → enter reason → banner disappears (status terminal), AI can propose a new fix → banner re-arms. +- Proposed → Apply → Verifying → "Mark partial" (overflow) → enter notes → Partial state shows notes → "Finish it" → back to Verifying. +- Proposed → Apply → chat "yep that fixed it" → wait for AI next turn → AI-confirming banner (accent blue) → Confirm → Resolve flow. +- Verifying → click Escalate in task-lane action bar → intercept popover → "Didn't work" (Enter) → status flips to applied_failed → Escalation package opens. +- Verifying → click Resolve in task-lane action bar → status auto-flips to applied_success → ResolutionNotePreview opens pre-filled. + +- [ ] **Step 3: Final commit** + +```bash +git add docs/ +git commit -m "docs(pilot): Phase 8 fix outcome banner — handoff + migration doc" +``` + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec element | Covered by | +|---|---| +| Banner replaces task-lane Suggested Fix card | Tasks 7-9 (component), Task 11 (integration), Task 12 (removal) | +| Proposed state with confidence + description | Task 7 | +| Verifying state with amber pulse, ✓/✕/overflow | Task 8 | +| Partial apply with notes, non-terminal | Tasks 1, 3, 8 | +| AI-inferred `[FIX_OUTCOME]` marker + confirm banner | Tasks 4, 5, 9, 11 | +| Escalate intercept with "didn't work" default | Tasks 10, 11 | +| Nudge after 3 post-apply messages | Tasks 9, 11 | +| Resolve-while-verifying auto-success | Task 11 | +| `session_suggested_fixes.status` + companion columns | Task 1 | +| PATCH `/outcome` endpoint | Task 3 | +| Anti-parrot compliance of prompt addition | Task 5 | +| Remove deprecated `SuggestedFix.tsx` | Task 12 | +| Smoke-test all paths | Task 13 | + +**Placeholder scan:** No TBDs, no "add error handling," no "similar to Task N." Every code block is complete. + +**Type consistency:** `FixStatus`, `FixOutcome`, `BannerMode`, `AIOutcomeProposal` — spelled the same across Tasks 2, 6, 7, 8, 9, 10, 11. Endpoint method names (`patchOutcome`, `_parse_fix_outcome_marker`, `SessionSuggestedFixOutcomeRequest`) consistent. + +**Risks called out:** +- Task 4 introduces `ai_outcome_proposal` column that Task 1 didn't anticipate — Task 4 flags this and instructs to amend Task 1's migration before moving on. +- The apply flow (Task 11, Step 2) uses the existing decision endpoint; the exact wire-up depends on whether the apply path runs a script, opens Script Builder, or both. The executing engineer should read `AssistantChatPage.tsx`'s current `onActivate` handler for the suggested fix and adapt. +- Frontend has no component test harness — verification is build + manual browser smoke (Task 11 Step 6, Task 13 Step 2). + +--- + +**Plan complete and saved to `docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md`. Two execution options:** + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints + +**Which approach?**