# 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](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.ts` — `patchScript` method
- `frontend/src/api/scriptBuilder.ts` — `createSession(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**

```bash
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`):

```python
"""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**

```bash
cd /config/workspace/resolutionflow/backend && alembic upgrade head
```

Verify:

```bash
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
```

```bash
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**

```bash
cd /config/workspace/resolutionflow/backend && alembic downgrade -1 && alembic upgrade head
```

Expected: both clean, no errors.

- [ ] **Step 5: Commit**

```bash
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:

```python
    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**

```bash
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**

```bash
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:

```python
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:

```python
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**

```bash
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**

```bash
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`:

```python
"""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**

```bash
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:

```python
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:

```python
    # 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:

```python
    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**

```bash
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**

```bash
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**

```bash
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`:

```python
"""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)**

```bash
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:

```python
@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**

```bash
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**

```bash
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:

```ts
  /**
   * 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:
```bash
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:

```ts
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**

```bash
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
```

Expected: clean.

- [ ] **Step 4: Commit**

```bash
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**

```tsx
/**
 * 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**

```bash
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
```

Expected: clean.

- [ ] **Step 3: Commit**

```bash
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**

```tsx
/**
 * 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**

```bash
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**

```bash
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**

```tsx
/**
 * 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**

```bash
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
```

Expected: clean.

- [ ] **Step 4: Commit**

```bash
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:

```ts
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:

```tsx
{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**

```bash
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**

```bash
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:

```tsx
<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**

```bash
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
```

Expected: clean.

- [ ] **Step 3: Commit**

```bash
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:

```tsx
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:

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

Update `resetSessionDerivedState`:

```tsx
setChatTab('chat')
setScriptBuilderHasProgress(false)
```

- [ ] **Step 2: Derive `showTabStrip` from fix state**

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

```tsx
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):

```tsx
{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:

```tsx
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:

```tsx
{/* 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**

```bash
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**

```bash
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:

```tsx
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:

```tsx
<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**

```bash
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**

```bash
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:
```bash
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 (5–10 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**

```bash
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**

```bash
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?**
