Files
resolutionflow/docs/FlowAssist_Migration/phase-9-implementation-plan.md
Michael Chihlas 563bb1aa6f docs(pilot): Phase 9 implementation plan
14-task plan covering:
- DB migration for origin + partial unique index on script_builder_sessions
- Pydantic schemas for inline origin + PATCH /script
- POST /script-builder/sessions idempotent for pilot_inline + auth
- list_sessions + count_user_sessions filtered to standalone
- PATCH /suggested-fixes/:id/script (bumps state_version, no applied_at)
- Frontend API client additions
- ChatTabStrip, ScriptBuilderTab (controller), InlineNoTemplateDialog
- TemplateMatchPanel 'I ran this' action
- EscalateInterceptDialog fourth 'partial' choice
- AssistantChatPage integration + applyFix call-site relocation
- Docs + handoff updates

Paired with the spec at phase-9-script-builder-tab.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:03:57 -04:00

68 KiB
Raw Permalink Blame History

FlowPilot Phase 9 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: Ship the remaining two FlowPilot migration items (tabbed Script Builder inside the chat; NoTemplateDialog relocation) plus two Phase 8 cleanups (EscalateInterceptDialog partial choice; applied_at stamp semantics correction).

Architecture: Adds origin to script_builder_sessions (plus a partial unique index for idempotent inline create), a new PATCH /suggested-fixes/:id/script endpoint, and a new ScriptBuilderTab controller in the chat region. ScriptBuilderChat stays presentational — the new controller owns session lifecycle + submit. applyFix call site moves from banner click to three explicit run-declaring surfaces.

Tech Stack: Python 3.11 + FastAPI + SQLAlchemy 2.0 async + Alembic + Pydantic v2 (backend); React 19 + Vite + TypeScript + Tailwind v4 + Monaco (frontend).

Spec reference: docs/FlowAssist_Migration/phase-9-script-builder-tab.md


File Structure

Backend — new

  • backend/alembic/versions/<hash>_script_builder_origin.py — migration
  • backend/tests/test_fix_script_endpoint.py — PATCH /script tests
  • backend/tests/test_script_builder_inline.py — inline session tests (idempotency, auth, list/count filter)

Backend — modified

  • backend/app/models/script_builder_session.py — add origin column
  • backend/app/schemas/script_builder.py — extend ScriptBuilderCreateRequest with origin + ai_session_id
  • backend/app/schemas/session_suggested_fix.py — add SessionSuggestedFixScriptRequest
  • backend/app/api/endpoints/script_builder.py — validate + resolve inline origin in create_session
  • backend/app/api/endpoints/session_suggested_fixes.py — add PATCH /script endpoint
  • backend/app/services/script_builder_service.py — persist origin; idempotent inline lookup; filter list_sessions / count_user_sessions to standalone

Frontend — new

  • frontend/src/components/pilot/ChatTabStrip.tsx[Chat] [Script Builder ●] tab strip
  • frontend/src/components/pilot/ScriptBuilderTab.tsx — controller (session lifecycle + mode toggle + submit)
  • frontend/src/components/pilot/InlineNoTemplateDialog.tsx — chat-region render wrapper for the existing NoTemplateDialog

Frontend — modified

  • frontend/src/api/sessionSuggestedFixes.tspatchScript method
  • frontend/src/api/scriptBuilder.tscreateSession(language, options?) takes optional origin + aiSessionId
  • frontend/src/components/pilot/script/TemplateMatchPanel.tsx — new "✓ I ran this" button; calls parent callback
  • frontend/src/components/pilot/EscalateInterceptDialog.tsx — fourth "I applied some of it — partial" choice
  • frontend/src/pages/AssistantChatPage.tsx — tab strip + ScriptBuilderTab mount + banner routing + applyFix call-site moves + inline NoTemplateDialog render
  • frontend/src/components/pilot/TaskLane.tsx — remove NoTemplateDialog from bottomSlot

Frontend — unchanged (intentionally)

  • frontend/src/components/script-builder/ScriptBuilderChat.tsx — presentational, stays pure
  • frontend/src/pages/ScriptBuilderPage.tsx — standalone path preserved (defaults)
  • frontend/src/components/pilot/script/NoTemplateDialog.tsx — existing three-card UX unchanged (new wrapper adjusts placement only)

Task 1: DB migration — add origin column + constraints + partial unique index

Files:

  • Create: backend/alembic/versions/<hash>_script_builder_origin.py (hash chosen by alembic)

  • No tests in this task — exercised by Task 4 + Task 5 integration tests.

  • Step 1: Generate migration

cd /config/workspace/resolutionflow/backend && source venv/bin/activate \
  && alembic revision -m "add origin discriminator + inline idempotency to script_builder_sessions"

(Do NOT pass --rev-id — alembic generates the hash.)

  • Step 2: Write migration body

Open the newly-generated file. Replace the body (keep alembic's generated revision / down_revision):

"""add origin discriminator + inline idempotency to script_builder_sessions

Adds:
- origin VARCHAR(20) NOT NULL DEFAULT 'standalone' with CHECK enum
- invariant: pilot_inline rows must have ai_session_id
- partial unique index: one pilot_inline session per (user, pilot session)
"""
from alembic import op
import sqlalchemy as sa


revision = "<hash-alembic-generated>"
down_revision = "<parent>"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.add_column(
        "script_builder_sessions",
        sa.Column(
            "origin",
            sa.String(length=20),
            nullable=False,
            server_default=sa.text("'standalone'"),
        ),
    )
    op.create_check_constraint(
        "ck_script_builder_sessions_origin",
        "script_builder_sessions",
        "origin IN ('standalone', 'pilot_inline')",
    )
    op.create_check_constraint(
        "ck_script_builder_sessions_origin_ai_session",
        "script_builder_sessions",
        "origin <> 'pilot_inline' OR ai_session_id IS NOT NULL",
    )
    op.create_index(
        "ux_script_builder_sessions_pilot_inline",
        "script_builder_sessions",
        ["user_id", "ai_session_id"],
        unique=True,
        postgresql_where=sa.text("origin = 'pilot_inline'"),
    )
    # Drop the server_default — app code owns the default via model default.
    op.alter_column("script_builder_sessions", "origin", server_default=None)


def downgrade() -> None:
    op.drop_index(
        "ux_script_builder_sessions_pilot_inline",
        table_name="script_builder_sessions",
    )
    op.drop_constraint(
        "ck_script_builder_sessions_origin_ai_session",
        "script_builder_sessions",
        type_="check",
    )
    op.drop_constraint(
        "ck_script_builder_sessions_origin",
        "script_builder_sessions",
        type_="check",
    )
    op.drop_column("script_builder_sessions", "origin")
  • Step 3: Apply + verify
cd /config/workspace/resolutionflow/backend && alembic upgrade head

Verify:

docker exec -i resolutionflow_postgres psql -U postgres -d resolutionflow -tAc \
  "SELECT column_name FROM information_schema.columns WHERE table_name='script_builder_sessions' AND column_name='origin';"
# expected: origin
docker exec -i resolutionflow_postgres psql -U postgres -d resolutionflow -tAc \
  "SELECT indexname FROM pg_indexes WHERE indexname='ux_script_builder_sessions_pilot_inline';"
# expected: ux_script_builder_sessions_pilot_inline
  • Step 4: Round-trip downgrade/upgrade to confirm reversibility
cd /config/workspace/resolutionflow/backend && alembic downgrade -1 && alembic upgrade head

Expected: both clean, no errors.

  • Step 5: Commit
cd /config/workspace/resolutionflow && git add backend/alembic/versions/ \
  && git commit -m "$(cat <<'EOF'
feat(pilot): add origin + inline idempotency to script_builder_sessions

Phase 9 prep. Adds:
- origin VARCHAR(20) NOT NULL with CHECK ('standalone' | 'pilot_inline')
- invariant: pilot_inline rows must have ai_session_id
- partial unique index on (user_id, ai_session_id) WHERE origin='pilot_inline'
  — backs get-or-create idempotency for the inline Script Builder tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 2: SQLAlchemy model — add origin column

Files:

  • Modify: backend/app/models/script_builder_session.py

  • Step 1: Add the column to the model

Open the file. After the existing ai_session_id column (around line 59) and before created_at, add:

    origin: Mapped[str] = mapped_column(
        String(20),
        nullable=False,
        default="standalone",
        comment=(
            "Session origin — 'standalone' (from /script-builder) or "
            "'pilot_inline' (from FlowPilot Script Builder tab). "
            "Invariant: pilot_inline rows must have ai_session_id set."
        ),
    )
  • Step 2: Sanity-check imports + run a quick parse
docker exec resolutionflow_backend sh -c "cd /app && python -c 'from app.models.script_builder_session import ScriptBuilderSession; print(ScriptBuilderSession.__table__.columns.keys())'"

Expected: output includes origin alongside existing columns.

  • Step 3: Commit
cd /config/workspace/resolutionflow && git add backend/app/models/script_builder_session.py \
  && git commit -m "$(cat <<'EOF'
feat(pilot): script_builder_sessions.origin on SQLAlchemy model

Mirrors the DB column added in the prior migration. App-level default
is 'standalone' so existing callers of ScriptBuilderSession(...) work
without code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 3: Backend schemas — extend create request + add PATCH /script request

Files:

  • Modify: backend/app/schemas/script_builder.py

  • Modify: backend/app/schemas/session_suggested_fix.py

  • Step 1: Extend ScriptBuilderCreateRequest

Open backend/app/schemas/script_builder.py. At the top, confirm Literal is imported from typing (add if missing). The UUID import is already present.

Replace the ScriptBuilderCreateRequest class with:

class ScriptBuilderCreateRequest(BaseModel):
    """Request to start (or get-or-create, for inline origin) a builder session.

    When `origin='pilot_inline'`, `ai_session_id` is REQUIRED and must
    reference a pilot session owned by the current user. The endpoint's
    get-or-create semantics kick in: if a pilot_inline session already
    exists for (user_id, ai_session_id), that row is returned instead of
    creating a duplicate.
    """
    language: str = Field(
        default="powershell",
        pattern=r"^(powershell|bash|python)$",
        description="Script language",
    )
    origin: Literal["standalone", "pilot_inline"] = "standalone"
    ai_session_id: UUID | None = None
  • Step 2: Add SessionSuggestedFixScriptRequest

Open backend/app/schemas/session_suggested_fix.py. After SessionSuggestedFixOutcomeRequest (near the end of the "decision / outcome" section), add:

class SessionSuggestedFixScriptRequest(BaseModel):
    """Engineer-submitted drafted script for a suggested fix.

    Called when the inline Script Builder tab's Submit action fires. The
    fix must be non-terminal (still proposed/applied_partial). Setting
    the script does NOT stamp applied_at — a draft is not an application.
    """
    ai_drafted_script: str = Field(..., min_length=1, max_length=50_000)
    ai_drafted_parameters: dict[str, Any] | None = None

(Any is already imported at the top of the file — confirmed during Phase 8.)

  • Step 3: Verify parse
docker exec resolutionflow_backend sh -c "cd /app && python -c '
from app.schemas.script_builder import ScriptBuilderCreateRequest
from app.schemas.session_suggested_fix import SessionSuggestedFixScriptRequest
print(\"OK\")
'"

Expected: OK.

  • Step 4: Commit
cd /config/workspace/resolutionflow && git add backend/app/schemas/ \
  && git commit -m "$(cat <<'EOF'
feat(pilot): pydantic schemas for inline origin + script PATCH

- ScriptBuilderCreateRequest gains origin ('standalone' | 'pilot_inline')
  and optional ai_session_id. Handler-side validation (next task) enforces
  pilot_inline ⇒ ai_session_id required + owned by caller.
- SessionSuggestedFixScriptRequest added for the new PATCH /script
  endpoint (Phase 9 Task 6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 4: POST /script-builder/sessions — idempotent pilot_inline + auth + service filter

Files:

  • Modify: backend/app/api/endpoints/script_builder.py

  • Modify: backend/app/services/script_builder_service.py

  • Create: backend/tests/test_script_builder_inline.py

  • Step 1: Read existing handler + service to understand integration points

Read backend/app/api/endpoints/script_builder.py and backend/app/services/script_builder_service.py around the create_session / list_sessions / count_user_sessions functions. You're adding behavior, not restructuring — keep the existing call shape where possible.

  • Step 2: Write the failing tests

Create backend/tests/test_script_builder_inline.py:

"""Integration tests for inline pilot_inline script_builder_session behavior.

Covers:
- Idempotent get-or-create for (user, ai_session_id) on origin='pilot_inline'
- Authorization: ai_session_id must belong to current user
- list_sessions + count_user_sessions default-scope to 'standalone'
"""
from __future__ import annotations

import pytest
from httpx import AsyncClient
from sqlalchemy import select, func
from uuid import uuid4

from app.models.ai_session import AISession
from app.models.script_builder_session import ScriptBuilderSession


async def _make_pilot_session(test_db, user) -> str:
    """Helper: create a minimal pilot session owned by `user`.

    Matches the existing pattern used by test_fix_outcome_endpoint.py.
    """
    session = AISession(
        id=uuid4(), user_id=user.id, account_id=user.account_id,
        session_type="tshoot", intake_type="ticket",
        intake_content={}, title="QA",
        status="active", confidence_tier="exploring", confidence_score=0.0,
    )
    test_db.add(session)
    await test_db.commit()
    return str(session.id)


@pytest.mark.asyncio
async def test_inline_create_is_idempotent(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """Second create with same (user, ai_session_id) returns the existing row."""
    ai_session_id = await _make_pilot_session(test_db, test_user)

    r1 = await client.post(
        "/api/v1/script-builder/sessions",
        json={"language": "powershell", "origin": "pilot_inline",
              "ai_session_id": ai_session_id},
        headers=auth_headers,
    )
    assert r1.status_code in (200, 201), r1.text
    first_id = r1.json()["id"]

    r2 = await client.post(
        "/api/v1/script-builder/sessions",
        json={"language": "powershell", "origin": "pilot_inline",
              "ai_session_id": ai_session_id},
        headers=auth_headers,
    )
    assert r2.status_code in (200, 201)
    assert r2.json()["id"] == first_id

    # DB confirms only one row
    row_count = await test_db.scalar(
        select(func.count()).select_from(ScriptBuilderSession).where(
            ScriptBuilderSession.user_id == test_user.id,
            ScriptBuilderSession.origin == "pilot_inline",
        )
    )
    assert row_count == 1


@pytest.mark.asyncio
async def test_inline_requires_ai_session_id(
    client: AsyncClient, auth_headers
):
    """origin='pilot_inline' without ai_session_id is rejected."""
    r = await client.post(
        "/api/v1/script-builder/sessions",
        json={"language": "powershell", "origin": "pilot_inline"},
        headers=auth_headers,
    )
    assert r.status_code == 400
    assert "ai_session_id" in r.text.lower()


@pytest.mark.asyncio
async def test_inline_ai_session_must_belong_to_caller(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """ai_session_id pointing at another user's session is rejected."""
    # Create pilot session owned by a DIFFERENT user
    from app.models.user import User
    from app.models.account import Account
    other_account = Account(id=uuid4(), name="other", plan="free", is_active=True)
    test_db.add(other_account)
    await test_db.flush()
    other_user = User(
        id=uuid4(), email="other@example.com",
        password_hash="x", name="Other", role="engineer",
        is_super_admin=False, is_team_admin=False, is_active=True,
        is_service_account=False, must_change_password=False,
        account_id=other_account.id, account_role="engineer",
    )
    test_db.add(other_user)
    await test_db.flush()
    other_session_id = await _make_pilot_session(test_db, other_user)

    r = await client.post(
        "/api/v1/script-builder/sessions",
        json={"language": "powershell", "origin": "pilot_inline",
              "ai_session_id": other_session_id},
        headers=auth_headers,
    )
    assert r.status_code in (403, 404), r.text


@pytest.mark.asyncio
async def test_list_sessions_excludes_inline(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """GET /script-builder/sessions returns only standalone rows."""
    ai_session_id = await _make_pilot_session(test_db, test_user)

    # Create one inline session
    await client.post(
        "/api/v1/script-builder/sessions",
        json={"language": "powershell", "origin": "pilot_inline",
              "ai_session_id": ai_session_id},
        headers=auth_headers,
    )
    # Create one standalone session
    await client.post(
        "/api/v1/script-builder/sessions",
        json={"language": "powershell"},
        headers=auth_headers,
    )

    r = await client.get("/api/v1/script-builder/sessions", headers=auth_headers)
    assert r.status_code == 200
    body = r.json()
    # Depending on response shape, this may be a list or {"sessions": [...]}.
    items = body if isinstance(body, list) else body.get("sessions", body.get("items", []))
    assert len(items) == 1
    assert items[0].get("origin", "standalone") == "standalone"


@pytest.mark.asyncio
async def test_inline_sessions_do_not_count_against_cap(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """Creating 5 pilot_inline sessions does not block a subsequent standalone."""
    # Create 5 distinct pilot sessions and attach inline builder sessions to each
    for _ in range(5):
        ai_session_id = await _make_pilot_session(test_db, test_user)
        r = await client.post(
            "/api/v1/script-builder/sessions",
            json={"language": "powershell", "origin": "pilot_inline",
                  "ai_session_id": ai_session_id},
            headers=auth_headers,
        )
        assert r.status_code in (200, 201), r.text

    # A standalone create should still succeed — inline sessions don't count
    r = await client.post(
        "/api/v1/script-builder/sessions",
        json={"language": "powershell"},
        headers=auth_headers,
    )
    assert r.status_code in (200, 201), r.text
  • Step 3: Run tests — confirm failure
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder_inline.py -v --override-ini='addopts='"

Expected: failures — endpoint doesn't yet accept origin / ai_session_id; list endpoint returns inline rows; the cap hits at 5.

  • Step 4: Update the service — list_sessions and count_user_sessions filter to origin='standalone'

In backend/app/services/script_builder_service.py, find list_sessions and count_user_sessions. Add include_inline: bool = False keyword arg to each; when False (the default), add .where(ScriptBuilderSession.origin == 'standalone') to the query.

Example shape:

async def list_sessions(
    db: AsyncSession, user_id: uuid.UUID, *, include_inline: bool = False,
) -> list[ScriptBuilderSession]:
    stmt = select(ScriptBuilderSession).where(
        ScriptBuilderSession.user_id == user_id,
        ScriptBuilderSession.deleted_at.is_(None),  # or whatever existing guard
    )
    if not include_inline:
        stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
    stmt = stmt.order_by(ScriptBuilderSession.updated_at.desc())
    result = await db.execute(stmt)
    return list(result.scalars())

async def count_user_sessions(
    db: AsyncSession, user_id: uuid.UUID, *, include_inline: bool = False,
) -> int:
    stmt = select(func.count()).select_from(ScriptBuilderSession).where(
        ScriptBuilderSession.user_id == user_id,
        ScriptBuilderSession.deleted_at.is_(None),
    )
    if not include_inline:
        stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
    return await db.scalar(stmt) or 0

(Adapt to match the existing function signatures — db may be first or last positional arg; copy whatever pattern is already there.)

  • Step 5: Update the endpoint — auth + idempotency

In backend/app/api/endpoints/script_builder.py, in the create_session handler, before calling the service:

    # Phase 9: inline origin validation + authorization
    if body.origin == "pilot_inline":
        if body.ai_session_id is None:
            raise HTTPException(
                status_code=400,
                detail="ai_session_id is required when origin='pilot_inline'",
            )
        # Ownership check: the pilot session must belong to the current user.
        # Reuse the existing _load_session_or_404 helper used elsewhere.
        await _load_session_or_404(db, body.ai_session_id)

        # Idempotent get-or-create: if a pilot_inline row exists for this
        # (user, ai_session_id), return it.
        existing = await db.scalar(
            select(ScriptBuilderSession).where(
                ScriptBuilderSession.user_id == current_user.id,
                ScriptBuilderSession.ai_session_id == body.ai_session_id,
                ScriptBuilderSession.origin == "pilot_inline",
            )
        )
        if existing is not None:
            return existing

After the ownership/idempotency short-circuit, continue into the service's existing create path. Pass origin + ai_session_id to the service's row creation. If the service has a helper that builds the row, add both kwargs there.

For the service creation path, handle the race (concurrent POSTs) via an integrity-error catch:

    try:
        session = await script_builder_service.create_session(
            db, user_id=current_user.id, language=body.language,
            origin=body.origin, ai_session_id=body.ai_session_id,
        )
    except IntegrityError:
        await db.rollback()
        # Race: another request won the unique index. Re-read.
        existing = await db.scalar(
            select(ScriptBuilderSession).where(
                ScriptBuilderSession.user_id == current_user.id,
                ScriptBuilderSession.ai_session_id == body.ai_session_id,
                ScriptBuilderSession.origin == "pilot_inline",
            )
        )
        if existing is None:
            raise
        return existing

Add imports as needed: from sqlalchemy.exc import IntegrityError, from sqlalchemy import select, the model import, _load_session_or_404 (grep for where other pilot endpoints import it).

Also: update the cap-check in create_session to pass include_inline=False (which is the default now, but be explicit for future readers).

  • Step 6: Run tests — expect passing
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder_inline.py -v --override-ini='addopts='"

Expected: 5 passed.

  • Step 7: Sanity-check that unrelated tests still pass
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder.py tests/test_session_suggested_fixes_api.py tests/test_fix_outcome_endpoint.py tests/test_fix_outcome_marker.py -v --override-ini='addopts='"

Expected: pass except for the pre-existing test_record_decision_persists_and_bumps_state_version (documented as Phase 8 Issue #4).

  • Step 8: Commit
cd /config/workspace/resolutionflow && git add backend/ \
  && git commit -m "$(cat <<'EOF'
feat(pilot): inline Script Builder session — idempotent create + auth + filtered list

POST /script-builder/sessions now supports origin='pilot_inline':
- Requires ai_session_id; validates it against current user ownership.
- Get-or-create: returns existing row for (user, ai_session_id) pair.
- Partial unique index on the DB backs the invariant; races resolve to
  the single winner row.

list_sessions + count_user_sessions default-scope to origin='standalone'
so inline scratch sessions don't pollute the /script-builder dashboard
or count against the 5-session cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 5: PATCH /suggested-fixes/:id/script — new endpoint

Files:

  • Modify: backend/app/api/endpoints/session_suggested_fixes.py

  • Create: backend/tests/test_fix_script_endpoint.py

  • Step 1: Write the failing tests

Create backend/tests/test_fix_script_endpoint.py:

"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script."""
from __future__ import annotations

import pytest
from httpx import AsyncClient
from sqlalchemy import select
from uuid import uuid4
from datetime import datetime, timezone

from app.models.ai_session import AISession
from app.models.session_suggested_fix import SessionSuggestedFix


async def _make_session_with_fix(
    test_db, user, *, status: str = "proposed", with_script: bool = False,
) -> tuple[str, str]:
    """Create a pilot session + suggested fix for tests. Returns (sid, fid)."""
    session = AISession(
        id=uuid4(), user_id=user.id, account_id=user.account_id,
        session_type="tshoot", intake_type="ticket",
        intake_content={}, title="QA",
        status="active", confidence_tier="exploring", confidence_score=0.0,
    )
    test_db.add(session)
    await test_db.flush()
    fix = SessionSuggestedFix(
        id=uuid4(), session_id=session.id, account_id=user.account_id,
        title="QA: test fix", description="desc", confidence_pct=80,
        status=status,
        ai_drafted_script="pre-existing" if with_script else None,
    )
    test_db.add(fix)
    await test_db.commit()
    return str(session.id), str(fix.id)


@pytest.mark.asyncio
async def test_patch_script_happy_path(
    client: AsyncClient, test_user, auth_headers, test_db
):
    sid, fid = await _make_session_with_fix(test_db, test_user)
    r = await client.patch(
        f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
        json={"ai_drafted_script": "Write-Host 'hello'"},
        headers=auth_headers,
    )
    assert r.status_code == 200, r.text
    body = r.json()
    assert body["ai_drafted_script"] == "Write-Host 'hello'"
    assert body["applied_at"] is None  # draft ≠ apply
    assert body["status"] == "proposed"


@pytest.mark.asyncio
async def test_patch_script_bumps_state_version(
    client: AsyncClient, test_user, auth_headers, test_db
):
    sid, fid = await _make_session_with_fix(test_db, test_user)
    before = await test_db.scalar(
        select(AISession.state_version).where(AISession.id == uuid4()).where(
            AISession.id == __import__("uuid").UUID(sid)
        )
    )
    r = await client.patch(
        f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
        json={"ai_drafted_script": "echo hi"},
        headers=auth_headers,
    )
    assert r.status_code == 200
    after = await test_db.scalar(
        select(AISession.state_version).where(
            AISession.id == __import__("uuid").UUID(sid)
        )
    )
    assert after == (before or 0) + 1


@pytest.mark.asyncio
async def test_patch_script_rejects_terminal_fix(
    client: AsyncClient, test_user, auth_headers, test_db
):
    sid, fid = await _make_session_with_fix(test_db, test_user, status="applied_success")
    r = await client.patch(
        f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
        json={"ai_drafted_script": "echo hi"},
        headers=auth_headers,
    )
    assert r.status_code == 409


@pytest.mark.asyncio
async def test_patch_script_rejects_empty_body(
    client: AsyncClient, test_user, auth_headers, test_db
):
    sid, fid = await _make_session_with_fix(test_db, test_user)
    r = await client.patch(
        f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
        json={"ai_drafted_script": ""},
        headers=auth_headers,
    )
    assert r.status_code == 422  # pydantic min_length=1


@pytest.mark.asyncio
async def test_patch_script_404_on_wrong_session(
    client: AsyncClient, test_user, auth_headers, test_db
):
    _, fid = await _make_session_with_fix(test_db, test_user)
    wrong_sid = str(uuid4())
    r = await client.patch(
        f"/api/v1/ai-sessions/{wrong_sid}/suggested-fixes/{fid}/script",
        json={"ai_drafted_script": "echo hi"},
        headers=auth_headers,
    )
    assert r.status_code == 404
  • Step 2: Run — confirm 404/405 (endpoint doesn't exist)
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_fix_script_endpoint.py -v --override-ini='addopts='"

Expected: 5 failures, likely 404 or 405 on PATCH.

  • Step 3: Add the endpoint

In backend/app/api/endpoints/session_suggested_fixes.py, near the existing patch_suggested_fix_outcome handler, add:

@router.patch(
    "/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script",
    response_model=SessionSuggestedFixResponse,
)
async def patch_suggested_fix_script(
    session_id: UUID,
    fix_id: UUID,
    body: SessionSuggestedFixScriptRequest,
    current_user: User = Depends(require_engineer_or_admin),
    db: AsyncSession = Depends(get_db),
) -> SessionSuggestedFixResponse:
    """Attach an engineer-drafted script to a suggested fix.

    Called by the inline Script Builder tab on Submit. Does NOT stamp
    applied_at — a draft is not an application. Bumps state_version so
    the Resolve/Escalate preview bundles regenerate.
    """
    await _load_session_or_404(db, session_id)

    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")

    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.ai_drafted_script = body.ai_drafted_script
    fix.ai_drafted_parameters = body.ai_drafted_parameters

    # Bump state_version on the parent session — previews cached by
    # (session_id, state_version) must regenerate to reflect the new draft.
    await db.execute(
        update(AISession)
        .where(AISession.id == session_id)
        .values(state_version=AISession.state_version + 1)
    )

    await db.commit()
    await db.refresh(fix)
    return SessionSuggestedFixResponse.model_validate(fix)

Add imports as needed: SessionSuggestedFixScriptRequest, AISession (if not already imported), and update from sqlalchemy.

  • Step 4: Run — expect 5 passing
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_fix_script_endpoint.py -v --override-ini='addopts='"

Expected: 5 passed.

  • Step 5: Commit
cd /config/workspace/resolutionflow && git add backend/ \
  && git commit -m "$(cat <<'EOF'
feat(pilot): PATCH /suggested-fixes/:id/script endpoint

Called by the inline Script Builder tab on Submit. Writes
ai_drafted_script + ai_drafted_parameters to the fix without stamping
applied_at (a draft is not an application — that's §5 of the Phase 9
spec). Bumps state_version so Resolve/Escalate preview bundles
regenerate.

409 on terminal fix status. 404 on wrong session. 422 on empty script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 6: Frontend API client — patchScript + extended createSession

Files:

  • Modify: frontend/src/api/sessionSuggestedFixes.ts

  • Modify: frontend/src/api/scriptBuilder.ts (or equivalent — check the existing file name)

  • Step 1: Add patchScript to sessionSuggestedFixesApi

In frontend/src/api/sessionSuggestedFixes.ts, after the patchOutcome method (and before clearAIProposal), add:

  /**
   * Attach an engineer-drafted script to a suggested fix (inline Script
   * Builder Submit path). Does NOT stamp applied_at — the server treats
   * a draft as non-terminal progress. Bumps state_version so the
   * Resolve/Escalate preview regenerates.
   */
  async patchScript(
    sessionId: string,
    fixId: string,
    aiDraftedScript: string,
    aiDraftedParameters?: Record<string, unknown>,
  ): Promise<SessionSuggestedFix> {
    const r = await apiClient.patch<SessionSuggestedFix>(
      `/ai-sessions/${sessionId}/suggested-fixes/${fixId}/script`,
      {
        ai_drafted_script: aiDraftedScript,
        ai_drafted_parameters: aiDraftedParameters,
      },
    )
    return r.data
  },
  • Step 2: Extend createSession with inline-origin args

Find the script-builder API client. Grep:

grep -rn "script-builder/sessions" /config/workspace/resolutionflow/frontend/src/api/

In that file (likely frontend/src/api/scriptBuilder.ts), update createSession to accept an optional options bag:

export interface CreateSessionOptions {
  origin?: 'standalone' | 'pilot_inline'
  aiSessionId?: string
}

export async function createSession(
  language: string,
  options?: CreateSessionOptions,
): Promise<ScriptBuilderSession> {
  const r = await apiClient.post<ScriptBuilderSession>(
    '/script-builder/sessions',
    {
      language,
      origin: options?.origin,
      ai_session_id: options?.aiSessionId,
    },
  )
  return r.data
}

Existing callers pass only language — they continue to work (origin defaults to 'standalone' on the server).

  • Step 3: Verify types
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"

Expected: clean.

  • Step 4: Commit
cd /config/workspace/resolutionflow && git add frontend/src/api/ \
  && git commit -m "$(cat <<'EOF'
feat(pilot): frontend API client — patchScript + inline createSession

sessionSuggestedFixesApi.patchScript(sessionId, fixId, script, params?)
hits the new PATCH /script endpoint.

scriptBuilder.createSession accepts an optional options bag with
origin + aiSessionId, defaulting to standalone when omitted so legacy
callers stay behavior-preserving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 7: ChatTabStrip component

Files:

  • Create: frontend/src/components/pilot/ChatTabStrip.tsx

  • Step 1: Write the component

/**
 * ChatTabStrip — two-tab strip at the top of the chat region:
 * [Chat]  [Script Builder ●]
 *
 * Visibility is controlled by the parent (AssistantChatPage) — this
 * component renders whenever it's mounted. The parent decides whether
 * to mount it based on fix state.
 *
 * Tab switching uses onChange; the parent toggles display:none on the
 * tab contents so state is preserved across switches.
 */
import { cn } from '@/lib/utils'

export type ChatTab = 'chat' | 'script_builder'

export interface ChatTabStripProps {
  active: ChatTab
  onChange: (tab: ChatTab) => void
  /** When true, shows the amber indicator dot on the Script Builder tab. */
  scriptBuilderHasProgress?: boolean
}

export function ChatTabStrip({
  active, onChange, scriptBuilderHasProgress,
}: ChatTabStripProps) {
  return (
    <div
      role="tablist"
      className="flex gap-1 px-4 pt-2 border-b border-default bg-bg-sidebar"
    >
      <TabButton
        label="Chat"
        active={active === 'chat'}
        onClick={() => onChange('chat')}
      />
      <TabButton
        label="Script Builder"
        active={active === 'script_builder'}
        onClick={() => onChange('script_builder')}
        indicator={scriptBuilderHasProgress}
      />
    </div>
  )
}

function TabButton({
  label, active, onClick, indicator,
}: {
  label: string
  active: boolean
  onClick: () => void
  indicator?: boolean
}) {
  return (
    <button
      role="tab"
      aria-selected={active}
      onClick={onClick}
      className={cn(
        'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors',
        'border-b-2 -mb-px',
        active
          ? 'text-heading border-accent bg-bg-page'
          : 'text-muted-foreground border-transparent hover:text-primary hover:bg-white/[0.04]',
      )}
    >
      {label}
      {indicator && (
        <span
          className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-warning align-middle"
          aria-label="unsaved progress"
        />
      )}
    </button>
  )
}

export default ChatTabStrip
  • Step 2: Build
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"

Expected: clean.

  • Step 3: Commit
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/ChatTabStrip.tsx \
  && git commit -m "$(cat <<'EOF'
feat(pilot): ChatTabStrip component — [Chat] [Script Builder ●]

Two-tab strip for the chat region. Parent controls mounting (strip only
appears when the fix needs a script drafted). Indicator dot signals
in-progress draft state. Tab switching via onChange callback; parent
handles display:none toggling so tab contents preserve state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 8: ScriptBuilderTab controller

Files:

  • Create: frontend/src/components/pilot/ScriptBuilderTab.tsx

  • Step 1: Inspect existing pieces we're stitching together

Read these files to understand the surfaces:

  • frontend/src/components/script-builder/ScriptBuilderChat.tsx — the presentational chat (takes messages, language, onViewScript, onSaveScript, isLoading).
  • frontend/src/pages/ScriptBuilderPage.tsx — how ScriptBuilderChat is wired today; see how messages are sent via sendMessage / how onSaveScript is handled.
  • frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx — the existing Monaco wrapper.
  • frontend/src/api/scriptBuilder.ts — existing API functions.

Take note of:

  • The message-send function signature.

  • The "latest script" field from a session (likely latest_script + latest_script_filename).

  • How CodeModeEditor is mounted (props, theme, language).

  • Step 2: Write the controller

/**
 * ScriptBuilderTab — inline Script Builder controller mounted in the
 * FlowPilot chat region when a fix needs a script drafted.
 *
 * Owns:
 *  - The inline `script_builder_sessions` row (get-or-create via the
 *    POST endpoint with origin='pilot_inline' + ai_session_id).
 *  - AI message state (reuses existing ScriptBuilderChat + sendMessage API).
 *  - Monaco buffer for the "Write it myself" mode.
 *  - Submit → PATCH /suggested-fixes/:id/script (no applied_at stamp).
 *
 * ScriptBuilderChat stays a pure display component — this controller
 * wires its onSaveScript to the inline submit path.
 */
import { useEffect, useRef, useState } from 'react'
import { Sparkles, Pencil } from 'lucide-react'
import { cn } from '@/lib/utils'
import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat'
import { sessionSuggestedFixesApi, type SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
import {
  createSession as createScriptBuilderSession,
  sendMessage as sendScriptBuilderMessage,
  listMessages as listScriptBuilderMessages,
} from '@/api/scriptBuilder'
import { CodeModeEditor } from '@/components/tree-editor/code-mode/CodeModeEditor'
import type { ScriptBuilderMessage } from '@/types'

export interface ScriptBuilderTabProps {
  fix: SessionSuggestedFix
  pilotSessionId: string
  /** Fires whenever in-progress state changes (for the ChatTabStrip dot). */
  onProgressChange: (hasProgress: boolean) => void
  /** Fires on successful submit; parent uses this to refresh the fix and hide the tab. */
  onScriptDrafted: (updated: SessionSuggestedFix) => void
}

type Mode = 'ai' | 'editor'

export function ScriptBuilderTab({
  fix, pilotSessionId, onProgressChange, onScriptDrafted,
}: ScriptBuilderTabProps) {
  const [builderSessionId, setBuilderSessionId] = useState<string | null>(null)
  const [mode, setMode] = useState<Mode>('ai')
  const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
  const [editorBuffer, setEditorBuffer] = useState<string>(
    () => scaffoldForLanguage('powershell', fix.description),
  )
  const [aiLoading, setAiLoading] = useState(false)
  const [submitting, setSubmitting] = useState(false)
  const [error, setError] = useState<string | null>(null)
  const [latestScript, setLatestScript] = useState<string | null>(null)

  // Track indicator-dot state and relay to parent.
  useEffect(() => {
    const hasProgress = messages.length > 0 || editorBuffer.trim() !== scaffoldForLanguage('powershell', fix.description).trim()
    onProgressChange(hasProgress)
  }, [messages.length, editorBuffer, fix.description, onProgressChange])

  // Get-or-create the inline session on mount.
  useEffect(() => {
    let cancelled = false
    ;(async () => {
      try {
        const s = await createScriptBuilderSession('powershell', {
          origin: 'pilot_inline',
          aiSessionId: pilotSessionId,
        })
        if (cancelled) return
        setBuilderSessionId(s.id)
        // Fetch any existing messages for resume.
        const existing = await listScriptBuilderMessages(s.id)
        if (cancelled) return
        setMessages(existing)
        if (s.latest_script) setLatestScript(s.latest_script)
      } catch (e) {
        if (!cancelled) setError('Failed to start Script Builder session.')
      }
    })()
    return () => { cancelled = true }
  }, [pilotSessionId])

  const handleSendMessage = async (content: string) => {
    if (!builderSessionId) return
    setAiLoading(true)
    try {
      const reply = await sendScriptBuilderMessage(builderSessionId, content)
      setMessages((prev) => [...prev, ...reply.messages])
      if (reply.latest_script) setLatestScript(reply.latest_script)
    } finally {
      setAiLoading(false)
    }
  }

  const handleSubmit = async (script: string) => {
    if (!script.trim()) return
    setSubmitting(true)
    setError(null)
    try {
      const updated = await sessionSuggestedFixesApi.patchScript(
        pilotSessionId, fix.id, script,
      )
      onScriptDrafted(updated)
    } catch {
      setError('Failed to save the drafted script.')
    } finally {
      setSubmitting(false)
    }
  }

  // AI mode uses whatever latestScript the AI produced as the body on save.
  const handleAiSave = () => {
    if (latestScript) void handleSubmit(latestScript)
  }

  return (
    <div className="flex flex-col h-full bg-bg-page">
      <div className="flex items-center justify-between px-4 py-2 border-b border-default">
        <div className="text-[12.5px] text-heading font-medium">
          Script Builder · {fix.title}
        </div>
        <div className="flex items-center gap-2">
          {mode === 'ai' ? (
            <button
              onClick={() => setMode('editor')}
              className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover"
            >
              <Pencil size={11} />
              Write it myself
            </button>
          ) : (
            <button
              onClick={() => setMode('ai')}
              className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover"
            >
              <Sparkles size={11} />
              Back to AI
            </button>
          )}
        </div>
      </div>

      {error && (
        <div className="mx-4 mt-2 text-[11.5px] text-danger bg-danger-dim border border-danger/30 rounded px-2 py-1">
          {error}
        </div>
      )}

      <div className="flex-1 min-h-0">
        <div className={cn('h-full', mode === 'ai' ? 'block' : 'hidden')}>
          <ScriptBuilderChat
            messages={messages}
            language="powershell"
            onViewScript={() => { /* optional future: inline preview */ }}
            onSaveScript={handleAiSave}
            isLoading={aiLoading}
          />
        </div>
        <div className={cn('h-full', mode === 'editor' ? 'block' : 'hidden')}>
          <CodeModeEditor
            value={editorBuffer}
            onChange={setEditorBuffer}
            language="powershell"
            readOnly={false}
          />
        </div>
      </div>

      {mode === 'editor' && (
        <div className="px-4 py-2 border-t border-default flex justify-end">
          <button
            onClick={() => handleSubmit(editorBuffer)}
            disabled={submitting || !editorBuffer.trim()}
            className={cn(
              'px-3.5 py-[7px] rounded text-[12.5px] font-semibold',
              'bg-accent text-[#0a0d14] hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed',
            )}
          >
            {submitting ? 'Saving…' : 'Submit script'}
          </button>
        </div>
      )}
    </div>
  )
}

function scaffoldForLanguage(language: string, fixDescription: string): string {
  if (language === 'bash' || language === 'python') {
    return `# ${fixDescription}\n\n`
  }
  return `# ${fixDescription}\r\n\r\n`
}

export default ScriptBuilderTab

Note on shape drift: The actual signatures of sendScriptBuilderMessage / listScriptBuilderMessages / the returned ScriptBuilderSession may differ from what I wrote above (field names, shape of the reply). Read the existing frontend/src/api/scriptBuilder.ts + ScriptBuilderPage.tsx and adapt the controller's calls to match — the structure of the component is what matters, not the exact wire names.

If any function (e.g. listMessages) doesn't exist in the current API client, check how ScriptBuilderPage fetches existing messages and copy that pattern.

  • Step 3: Build
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build"

Expected: clean. Adapt to any real-world API mismatches revealed by the build.

  • Step 4: Commit
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/ScriptBuilderTab.tsx \
  && git commit -m "$(cat <<'EOF'
feat(pilot): ScriptBuilderTab controller

Owns the inline Script Builder session lifecycle:
- Get-or-create (origin='pilot_inline', ai_session_id) on mount.
- Renders ScriptBuilderChat in AI mode and CodeModeEditor (Monaco) in
  'Write it myself' mode. Mode toggles via display:none so buffer and
  messages persist across switches.
- Submit → sessionSuggestedFixesApi.patchScript; emits onScriptDrafted
  to parent, which refreshes the fix and hides the tab strip.
- Relays in-progress state to the parent via onProgressChange for the
  ChatTabStrip's indicator dot.

ScriptBuilderChat is untouched (stays presentational). Persistence
semantics live on the controller, not the display component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 9: InlineNoTemplateDialog wrapper — chat-region placement

Files:

  • Create: frontend/src/components/pilot/InlineNoTemplateDialog.tsx

  • Step 1: Inspect the existing dialog + decide reuse strategy

Read frontend/src/components/pilot/script/NoTemplateDialog.tsx. The three-card UX is already built. We need to render it in the chat region (above the composer, like ProposalBanner), not inside the TaskLane's narrow bottomSlot.

Strategy: a thin wrapper that positions the existing component appropriately. NoTemplateDialog's internal grid is grid-cols-1 sm:grid-cols-3; in the chat region the sm: breakpoint will engage at its viewport-based trigger, and the chat region's width is sufficient for three cards. No internal change to NoTemplateDialog is required.

  • Step 2: Write the wrapper
/**
 * InlineNoTemplateDialog — chat-region placement wrapper for
 * NoTemplateDialog. Renders above the composer (slide-up animation
 * matching ProposalBanner), using the full chat-region width so the
 * three decision cards fit side-by-side.
 */
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
import type { ComponentProps } from 'react'

type Props = ComponentProps<typeof NoTemplateDialog>

export function InlineNoTemplateDialog(props: Props) {
  return (
    <div className="border-t border-default bg-bg-page animate-slide-up">
      <div className="px-5 py-3">
        <NoTemplateDialog {...props} />
      </div>
    </div>
  )
}

export default InlineNoTemplateDialog
  • Step 3: Build
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"

Expected: clean.

  • Step 4: Commit
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/InlineNoTemplateDialog.tsx \
  && git commit -m "$(cat <<'EOF'
feat(pilot): InlineNoTemplateDialog — chat-region placement wrapper

Slide-up wrapper around the existing NoTemplateDialog for rendering
in the chat region above the composer (parallel to ProposalBanner).
The chat region's width lets grid-cols-3 finally work as intended.

No change to NoTemplateDialog itself; decision callbacks and card
copy stay identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 10: TemplateMatchPanel — new "I ran this" action

Files:

  • Modify: frontend/src/components/pilot/script/TemplateMatchPanel.tsx

  • Step 1: Read the current component to find the existing button row

Open frontend/src/components/pilot/script/TemplateMatchPanel.tsx. Locate the section that renders the Generate / Copy / Edit Parameters buttons — you'll add a new primary button adjacent to these.

  • Step 2: Add a new prop + button

Extend the props interface:

export interface TemplateMatchPanelProps {
  // ... existing props ...
  /** Fires when the engineer declares the script was run. Parent calls
   *  applyFix() to stamp applied_at. */
  onMarkRun?: () => void
}

Find the button-row JSX. Add a new button, styled as accent-colored primary:

{onMarkRun && (
  <button
    onClick={onMarkRun}
    className="inline-flex items-center gap-1.5 px-3 py-[7px] rounded text-[12px] font-semibold bg-accent text-[#0a0d14] hover:brightness-110"
    aria-label="Mark the script as applied to start verifying the fix"
  >
    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
      <polyline points="20 6 9 17 4 12" />
    </svg>
    I ran this
  </button>
)}

Position it as the right-most action (prominent terminal action) in the existing row.

  • Step 3: Destructure the new prop

Make sure the component destructures onMarkRun from its props, or update the props pattern consistently with existing props.

  • Step 4: Build
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"

Expected: clean (no call sites provide onMarkRun yet, but it's optional).

  • Step 5: Commit
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/script/TemplateMatchPanel.tsx \
  && git commit -m "$(cat <<'EOF'
feat(pilot): TemplateMatchPanel — explicit 'I ran this' action

Generate and Copy alone don't declare a run — the engineer can walk
away after copying. Phase 9 §5 defines an explicit run-declaration
affordance so applied_at only stamps on the engineer's positive
commitment. Wiring from AssistantChatPage lands in Task 13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 11: EscalateInterceptDialog — fourth "partial" choice

Files:

  • Modify: frontend/src/components/pilot/EscalateInterceptDialog.tsx

  • Step 1: Add the fourth button

Open the file. Locate the three existing option buttons ("The fix didn't work", "It worked — escalating for another reason", "Never actually applied it"). Add a fourth option button after the "didn't work" autofocus button:

<button
  onClick={() => onChoose('applied_partial')}
  className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
>
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="12" cy="12" r="10" />
    <line x1="12" y1="8" x2="12" y2="12" />
    <line x1="12" y1="16" x2="12.01" y2="16" />
  </svg>
  <span className="flex-1">I applied some of it  partial</span>
</button>

The InterceptChoice type already includes 'applied_partial' via its FixOutcome | 'never_applied' definition — no type changes needed.

  • Step 2: Build
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"

Expected: clean.

  • Step 3: Commit
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/EscalateInterceptDialog.tsx \
  && git commit -m "$(cat <<'EOF'
feat(pilot): EscalateInterceptDialog — fourth 'partial' choice

Closes the gap Phase 8 final review flagged. When a fix is in
applied_partial state and the engineer escalates, the intercept no
longer forces them to approximate with didn't-work/worked/never-applied.

AssistantChatPage's handleInterceptChoice (Task 13) already dispatches
to patchOutcome for any FixOutcome value, so no handler change is
needed — the type already supports applied_partial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 12: AssistantChatPage — tab strip + tab mount + banner routing

Files:

  • Modify: frontend/src/pages/AssistantChatPage.tsx

This task scaffolds the new UI surface in the parent. Task 13 handles the trickier call-site moves.

  • Step 1: Add imports + state

Open frontend/src/pages/AssistantChatPage.tsx. Add imports:

import { ChatTabStrip, type ChatTab } from '@/components/pilot/ChatTabStrip'
import { ScriptBuilderTab } from '@/components/pilot/ScriptBuilderTab'
import { InlineNoTemplateDialog } from '@/components/pilot/InlineNoTemplateDialog'

Near the other Phase 8 state declarations (e.g., bannerCollapsed, postApplyMsgCount), add:

const [chatTab, setChatTab] = useState<ChatTab>('chat')
const [scriptBuilderHasProgress, setScriptBuilderHasProgress] = useState(false)

Update resetSessionDerivedState:

setChatTab('chat')
setScriptBuilderHasProgress(false)
  • Step 2: Derive showTabStrip from fix state

Near the other derived values (probably close to bannerMode):

const showTabStrip =
  activeFix != null
  && activeFix.status !== 'dismissed'
  && activeFix.status !== 'applied_success'
  && activeFix.status !== 'applied_failed'
  && !activeFix.script_template_id
  && !activeFix.ai_drafted_script

// Defensive: if the strip hides, snap back to the Chat tab.
useEffect(() => {
  if (!showTabStrip && chatTab === 'script_builder') setChatTab('chat')
}, [showTabStrip, chatTab])
  • Step 3: Mount the strip + tab content in the chat region

Find the chat region JSX (the scrollable messages list + composer). Wrap it so the tab strip renders above the messages area and the Script Builder tab renders beside (via display:none toggling):

{showTabStrip && (
  <ChatTabStrip
    active={chatTab}
    onChange={setChatTab}
    scriptBuilderHasProgress={scriptBuilderHasProgress}
  />
)}

<div className={cn('flex-1 min-h-0', chatTab === 'chat' ? 'block' : 'hidden')}>
  {/* existing chat messages + composer JSX remains here unchanged */}
</div>

{showTabStrip && activeFix && activeChatId && (
  <div className={cn('flex-1 min-h-0', chatTab === 'script_builder' ? 'block' : 'hidden')}>
    <ScriptBuilderTab
      fix={activeFix}
      pilotSessionId={activeChatId}
      onProgressChange={setScriptBuilderHasProgress}
      onScriptDrafted={(updated) => {
        setActiveFix(updated)
        setChatTab('chat')
        setScriptBuilderHasProgress(false)
      }}
    />
  </div>
)}
  • Step 4: Banner Apply routing — route no-draft case to the Script Builder tab

Find handleApplyFix. Update the routing:

const handleApplyFix = useCallback(() => {
  if (!activeFix) return
  if (activeFix.script_template_id) {
    setScriptPanelOpen(true) // existing TemplateMatchPanel flow, task lane
    return
  }
  if (activeFix.ai_drafted_script) {
    setScriptPanelOpen(true) // existing dialog flow, NOW rendering in chat region (Step 5)
    return
  }
  // No draft, no template — route to the Script Builder tab.
  setChatTab('script_builder')
}, [activeFix])

IMPORTANT: do NOT call sessionSuggestedFixesApi.applyFix(...) here anymore. That move happens in Task 13.

  • Step 5: Render InlineNoTemplateDialog in the chat region

Today's dialog renders inside TaskLane.bottomSlot (for both draft and template cases). For the drafted-script case, render it in the chat region instead. Find the existing NoTemplateDialog render (likely near the task-lane bottomSlot). Wrap the draft case and move it outside the TaskLane slot:

{/* In the chat region, just above the composer */}
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
  <InlineNoTemplateDialog
    fix={activeFix}
    onClose={() => setScriptPanelOpen(false)}
    onDecide={handleScriptDecision}
    busy={scriptDecisionBusy}
  />
)}

In the existing TaskLane.bottomSlot render, REMOVE the NoTemplateDialog branch (the no-template-but-drafted case). Keep the TemplateMatchPanel branch there — it stays in the task lane.

  • Step 6: Build + smoke-test
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build"

Expected: clean.

Start the dev stack and smoke-test:

  • Open a session where the AI has emitted [SUGGEST_FIX] without a drafted script. Banner appears.

  • Click Apply → the tab strip shows, Script Builder tab is active, tab contents render (you can verify the get-or-create endpoint fires in the Network panel).

  • Switch between Chat and Script Builder tabs — chat scroll position preserved.

  • Step 7: Commit

cd /config/workspace/resolutionflow && git add frontend/src/pages/AssistantChatPage.tsx \
  && git commit -m "$(cat <<'EOF'
feat(pilot): mount ChatTabStrip + ScriptBuilderTab + InlineNoTemplateDialog

Wires the three new components into AssistantChatPage:
- ChatTabStrip renders when the active fix needs a script drafted.
- ScriptBuilderTab sits alongside chat via display:none toggling so
  chat scroll position + builder state both persist.
- InlineNoTemplateDialog replaces the task-lane bottomSlot render for
  the drafted-script evaluation case; three cards finally fit.
- Banner Apply routing updated: no-draft/no-template → Script Builder
  tab; drafted → InlineNoTemplateDialog; template → unchanged path.

applyFix() call site moves land in the next task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 13: applyFix call-site relocation — stamp only on run-declaring actions

Files:

  • Modify: frontend/src/pages/AssistantChatPage.tsx

  • Step 1: Remove the applyFix call from handleApplyFix

Open frontend/src/pages/AssistantChatPage.tsx. Locate handleApplyFix. Remove the line that calls sessionSuggestedFixesApi.applyFix(...) (it was added in Phase 8 Issue #2). The handler should now only route to the right surface (you already did this in Task 12 Step 4).

  • Step 2: Add applyFix to handleScriptDecision for one_off and draft_template

Find handleScriptDecision. Both one_off and draft_template declare a run (their labels: "Run now, no template" and "Run now, templatize after"). Stamp applied_at in both branches:

const handleScriptDecision = useCallback(
  async (decision: UserDecision, options?: { editedScript?: string; parametersUsed?: Record<string, unknown> }) => {
    if (!activeFix || !activeChatId) return
    setScriptDecisionBusy(true)
    try {
      const result = await sessionSuggestedFixesApi.recordDecision(
        activeChatId, activeFix.id, decision, options,
      )
      // Phase 9 §5: one_off and draft_template declare a run. Stamp applied_at
      // to transition the fix into Verifying. build_template does NOT run.
      if (decision === 'one_off' || decision === 'draft_template') {
        try {
          const updated = await sessionSuggestedFixesApi.applyFix(
            activeChatId, activeFix.id,
          )
          setActiveFix(updated)
        } catch { /* non-fatal: engineer can still mark outcome later */ }
      }
      // ... existing side-effects (redirect to script builder for build_template, etc.) ...
    } finally {
      setScriptDecisionBusy(false)
    }
  },
  [activeFix, activeChatId],
)

(Keep whatever existing post-recordDecision logic is already in the handler — this diff adds the stamp, doesn't replace anything else.)

  • Step 3: Wire onMarkRun on TemplateMatchPanel

Find the TemplateMatchPanel render site. Add the onMarkRun prop:

<TemplateMatchPanel
  fix={activeFix}
  sessionId={activeChatId}
  onClose={() => setScriptPanelOpen(false)}
  onMarkRun={async () => {
    try {
      const updated = await sessionSuggestedFixesApi.applyFix(
        activeChatId, activeFix.id,
      )
      setActiveFix(updated)
      setScriptPanelOpen(false)
    } catch { /* non-fatal */ }
  }}
/>
  • Step 4: Build + smoke test
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build"

Manual browser QA:

  1. Fix with script_template_id → Apply → TemplateMatchPanel opens, banner stays on Proposed state. Click "I ran this" → banner flips to Verifying.
  2. Fix with ai_drafted_script → Apply → InlineNoTemplateDialog opens, banner stays on Proposed. Click one_off card → banner flips to Verifying. (Repeat with draft_template — same result.) Click build_template → navigates; banner stays on Proposed.
  3. Fix with neither → Apply → Script Builder tab opens, banner stays on Proposed. Draft + submit → tab disappears. Engineer clicks Apply again → InlineNoTemplateDialog opens. Pick one_off → banner flips to Verifying.
  • Step 5: Commit
cd /config/workspace/resolutionflow && git add frontend/src/pages/AssistantChatPage.tsx \
  && git commit -m "$(cat <<'EOF'
fix(pilot): applied_at stamps on run-declaring actions, not Apply click

Per Phase 9 §5. Before: banner Apply click stamped applied_at
regardless of whether the engineer had committed to running anything,
starting the Verifying timer prematurely. After:

- handleApplyFix no longer calls applyFix(). It just routes to the
  right surface (TemplateMatchPanel / InlineNoTemplateDialog / Script
  Builder tab).
- handleScriptDecision stamps applied_at for one_off + draft_template
  (both labels are 'Run now, …' — the click is the declaration).
  build_template does not stamp.
- TemplateMatchPanel's new 'I ran this' button calls applyFix via a
  new onMarkRun prop.
- Script Builder tab Submit does not stamp (a draft is not a run).

No backend change — the /apply endpoint is unchanged. Only call sites
move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task 14: Cleanup — TaskLane bottomSlot audit + handoff docs

Files:

  • Modify: frontend/src/components/pilot/TaskLane.tsx

  • Modify: docs/handoff/2026-04-22-flowpilot-migration.md

  • Modify: docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md

  • Step 1: Verify TaskLane bottomSlot is still needed

Grep:

grep -n "bottomSlot" /config/workspace/resolutionflow/frontend/src/components/pilot/TaskLane.tsx \
  /config/workspace/resolutionflow/frontend/src/pages/AssistantChatPage.tsx

If AssistantChatPage now only passes bottomSlot with the TemplateMatchPanel branch (no NoTemplateDialog), the slot is still used but narrower in purpose. Leave the prop definition alone (keep API stable).

If AssistantChatPage no longer passes bottomSlot at all, remove the prop from TaskLane.tsx's interface + destructure + render site.

  • Step 2: Update the handoff doc

docs/handoff/2026-04-22-flowpilot-migration.md:

  • last_commit bump to the Phase 9 tip.

  • Update frontmatter status line to reflect Phase 9 shipped ("Sprint 9/9 phases…" or equivalent).

  • Add a Phase 9 row to the "What shipped" table listing the tab strip, ScriptBuilderTab, PATCH /script endpoint, migration, and the applied_at semantics correction.

  • Mark open items #1 (NoTemplateDialog narrow-lane) and #3 (Tabbed Script Builder) as RESOLVED with a pointer to this plan + the spec.

  • Loose ends: /ultrareview still not run on the final branch; PR still not opened; browser QA of Phase 9 states not done from this branch.

  • Step 3: Update the migration spec

docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md:

  • Status line + "last updated" header bumped.

  • New Phase 9 section (510 lines) describing: tab strip + ScriptBuilderTab, InlineNoTemplateDialog relocation, PATCH /script endpoint, EscalateInterceptDialog partial choice, applied_at semantics correction. Link to the spec + implementation plan files.

  • Step 4: Run full test suite as a final guard

docker exec resolutionflow_backend sh -c "cd /app && pytest tests/ --override-ini='addopts=' -q"

Expected: everything passes except the pre-existing test_record_decision_persists_and_bumps_state_version failure (Phase 8 Issue #4, separately documented).

  • Step 5: Commit
cd /config/workspace/resolutionflow && git add -A \
  && git commit -m "$(cat <<'EOF'
docs(pilot): Phase 9 handoff + migration spec update

Marks open items #1 (NoTemplateDialog narrow-lane) and #3 (Tabbed
Script Builder) as resolved. Records the applied_at semantics
correction as shipped. Final Phase 9 row added to the 'What shipped'
table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Self-Review

Spec coverage:

Spec section Covered by
§1 Chat region tab strip Tasks 7, 12
§2 Script Builder tab content + controller Tasks 8, 12
§3 NoTemplateDialog relocation Tasks 9, 12
§4 Banner Apply routing (updated) Task 12 step 4
§5 Apply lifecycle (applied_at correction) Tasks 10, 13
§6 EscalateInterceptDialog partial Task 11
New migration (origin + partial unique index) Task 1
Model column Task 2
ScriptBuilderCreateRequest extended Task 3
SessionSuggestedFixScriptRequest Task 3
POST /script-builder/sessions idempotent + auth Task 4
list/count filter Task 4 step 4
PATCH /script endpoint + tests Task 5
Frontend API client Task 6
ChatTabStrip Task 7
ScriptBuilderTab Task 8
InlineNoTemplateDialog wrapper Task 9
TemplateMatchPanel "I ran this" Task 10
EscalateInterceptDialog fourth choice Task 11
applied_at call-site moves Task 13
Task-lane cleanup + docs Task 14

No gaps.

Placeholder scan: No TBD/TODO entries; no "add error handling" placeholders; every code step has full code. One intentional placeholder: <hash-alembic-generated> in Task 1 (standard Alembic workflow). The Task 8 code contains a paragraph acknowledging that sendScriptBuilderMessage / listScriptBuilderMessages API shapes may need adaptation — that's a knowable-by-reading note, not a "TBD" the implementer has to invent.

Type consistency: SessionSuggestedFix (interface), FixOutcome (type), InterceptChoice (type), ChatTab (type), ScriptBuilderSession (interface), ChatTabStripProps / ScriptBuilderTabProps / TemplateMatchPanelProps — all spelled identically across tasks. patchScript / applyFix / createSession / patchOutcome method names match between API client tasks and consumer tasks.

Risks called out inline:

  • Task 4 Step 5 may need minor adaptation if _load_session_or_404's signature has drifted or if the existing service has a subtle ownership check elsewhere.
  • Task 8's controller assumes API-client function names that might not exactly match the existing codebase — the task includes an explicit adapt-to-reality instruction.
  • Task 12 Step 5's "remove the draft case from TaskLane bottomSlot" is the only destructive change; guarded by the preceding render moving first.

Plan complete and saved to docs/FlowAssist_Migration/phase-9-implementation-plan.md. Two execution options:

1. Subagent-Driven (recommended) — 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?