6 Commits

Author SHA1 Message Date
9330ce4782 fix(pilot): two Phase 9 layout/state bugs surfaced by QA fixtures
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
1. EscalateInterceptDialog clipped off-screen.
   The dialog was positioned with `absolute bottom-full mb-2 left-0`
   under the assumption the Escalate button would have room above it.
   In practice the button lives in the chat-page action bar near
   y≈105, so the 302 px dialog overflows the top of the viewport
   and only the last option is visible.

   Switch to `top-full mt-2 right-0` — anchors the dialog below the
   button and aligns its right edge with the button (avoids overflow
   off the right when the button is in the right-side action cluster).

2. TemplateMatchPanel never renders on a fresh session.
   `handleApplyFix` for the script_template_id branch only sets
   `scriptPanelOpen=true`, but TemplateMatchPanel is mounted inside
   `TaskLane.bottomSlot`. On sessions with no questions/facts the
   lane defaults closed, so the panel exists in the React tree but
   inside an unrendered TaskLane — the user clicks Apply fix and
   nothing visibly changes.

   Fix: also `setShowTaskLane(true)` in that branch so the lane
   opens alongside the panel. The ai_drafted_script branch is fine
   (InlineNoTemplateDialog renders in the chat region, not in the
   lane), so it's left alone.

Both bugs were latent — they only surface on sessions that haven't
accumulated TaskLane state yet (questions/facts). Fresh sessions
created from the StartSessionInput hide them because the AI's first
turn populates questions and the lane auto-opens. Caught using the
new seed_phase9_qa_fixtures.py harness.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:08:50 -04:00
d68131a865 feat(seed): Phase 9 QA fixture seeder
Adds backend/scripts/seed_phase9_qa_fixtures.py — creates 4 ai_sessions
plus matching session_suggested_fixes that pre-bake the four backend
states the AI orchestrator must produce to mount the five conditional
Phase 9 components:

  A. no template, no draft     → ChatTabStrip + ScriptBuilderTab
  B. ai_drafted_script set      → InlineNoTemplateDialog
  C. script_template_id set     → TemplateMatchPanel
  D. applied_at + status=proposed → EscalateInterceptDialog (verify state)

Background: a Phase 9 QA pass against a regular session left these
five components unreached because the AI didn't emit SUGGEST_FIX in
time/at all. Seeding directly bypasses the AI and lets QA exercise
each surface deterministically.

UUIDs are deterministic (uuid5 over a fixed namespace) so re-runs
upsert. Pass --reset to wipe and recreate. Each session gets two
synthetic conversation messages so the chat header's canAct gate
(messages.length >= 2) opens up Resolve/Escalate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 00:08:38 -04:00
875bd924a9 fix(pilot): auto-scroll Resolve preview into view when opened
The ResolutionNotePreview popover renders inside TaskLane's
overflow-y-auto region at the bottom of the lane. On a 720px
viewport with the default question/check list expanded, the
popover lands below the visible scroll position — the engineer
clicks "Preview Resolve note", sees the button label flip to
"Showing", but no preview appears on screen.

Add a useEffect that calls scrollIntoView({block: 'nearest'}) on
the popover's outer div whenever `open` flips to true. block:
'nearest' scrolls just enough to make it visible without yanking
the lane to the top.

Discovered during Phase 9 QA. Reproduced at 1280x720; fix verified
visually in the same QA run (screenshots in
.gstack/qa-reports/phase9-*/).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:45:52 -04:00
49c6c8fd00 fix(seed): include cancel_at_period_end in test-user subscription INSERT
Discovered during Phase 9 QA: seed_test_users.py was missing the
cancel_at_period_end column in its subscriptions INSERT, but the
column is NOT NULL (added in 016_add_subscription_tables.py).
Result: seed crashed with NotNullViolationError before any users
were created, blocking auth in fresh dev environments.

Pre-existing on main; not introduced by the FlowPilot migration
branch. Default value: false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:36:04 -04:00
a77e8ea578 chore: bootstrap gstack team mode
Per gstack team-mode install: adds a PreToolUse hook that blocks
skill usage when gstack isn't installed globally, so contributors
are prompted to install it. Un-ignores the two required files
(.claude/settings.json, .claude/hooks/check-gstack.sh) while
keeping settings.local.json and other Claude state ignored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:17:06 -04:00
90252bc98f docs(claude-md): expand gstack section with full grouped command list
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 23:17:01 -04:00
9 changed files with 444 additions and 15 deletions

20
.claude/hooks/check-gstack.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Block skill usage when gstack is not installed globally.
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
cat >&2 <<'MSG'
BLOCKED: gstack is not installed globally.
gstack is required for AI-assisted work in this repo.
Install it:
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
cd ~/.claude/skills/gstack && ./setup --team
Then restart your AI coding tool.
MSG
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
exit 0
fi
echo '{}'

15
.claude/settings.json Normal file
View File

@@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\""
}
]
}
]
}
}

6
.gitignore vendored
View File

@@ -207,7 +207,11 @@ marimo/_lsp/
__marimo__/
# Claude Code (local config, agents, settings)
.claude/
.claude/*
!.claude/settings.json
!.claude/hooks/
.claude/hooks/*
!.claude/hooks/check-gstack.sh
.agents/
# Database dumps

View File

@@ -54,15 +54,16 @@ Re-indexes automatically on commit (PostToolUse hook). Manual refresh if stale:
### gstack skills
Always use `/browse` for web, never `mcp__claude-in-chrome__*`. Most-used:
Always use `/browse` for web, never `mcp__claude-in-chrome__*`.
- `/review` — pre-land PR review
- `/ship` — tests + review + PR creation
- `/browse` + `/qa` / `/qa-only` — headless browser testing (setup: Lesson 82)
- `/design-review` — visual QA
- `/investigate` — systematic debug with root cause
- `/codex` — OpenAI Codex second opinion
- `/plan-eng-review` / `/plan-design-review` / `/plan-ceo-review` — plan critiques
Available commands:
- **Planning & review:** `/autoplan`, `/plan-eng-review`, `/plan-design-review`, `/plan-ceo-review`, `/plan-devex-review`, `/devex-review`, `/review`, `/cso`, `/office-hours`
- **Design:** `/design-consultation`, `/design-shotgun`, `/design-html`, `/design-review`
- **Browser & QA:** `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/setup-browser-cookies`
- **Ship & deploy:** `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/setup-deploy`, `/document-release`
- **Debug & investigate:** `/investigate`, `/careful`, `/freeze`, `/guard`, `/unfreeze`
- **Other:** `/codex` (OpenAI second opinion), `/setup-gbrain`, `/retro`, `/learn`, `/gstack-upgrade`
### Git trailer

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
Seed Phase 9 QA fixtures: 4 ai_sessions + matching suggested_fixes that
exercise the five Phase 9 components which gate on a backend-emitted
`SUGGEST_FIX` action and don't fire reliably in normal local sessions.
Usage:
cd backend
python -m scripts.seed_phase9_qa_fixtures
python -m scripts.seed_phase9_qa_fixtures --reset # delete & recreate
Targets the super-admin from `seed_test_users.py`
(admin@resolutionflow.example.com) and their account. UUIDs are
deterministic (UUID5 over a fixed namespace) so re-runs are idempotent
without --reset.
Sessions created:
| # | Title | Phase 9 component reached when… |
|---|---------------------------------|-------------------------------------------------------|
| A | Phase 9 QA — no-template path | ChatTabStrip + ScriptBuilderTab + ProposalBanner |
| B | Phase 9 QA — drafted-script | InlineNoTemplateDialog + ProposalBanner |
| C | Phase 9 QA — template match | TemplateMatchPanel + ProposalBanner |
| D | Phase 9 QA — verify state | EscalateInterceptDialog (with new "partial" choice) |
Run /qa, then in the browser go to /pilot, click each session in the
sidebar, and exercise its Phase 9 surface. The session URLs are printed
at the end.
"""
import argparse
import asyncio
import sys
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
ADMIN_EMAIL = "admin@resolutionflow.example.com"
# Deterministic UUIDs so re-running the seeder updates rather than duplicates.
NS = uuid.UUID("00000000-0000-0000-0000-000000000901")
SESSION_A = uuid.uuid5(NS, "session-A-no-template")
SESSION_B = uuid.uuid5(NS, "session-B-drafted-script")
SESSION_C = uuid.uuid5(NS, "session-C-template-match")
SESSION_D = uuid.uuid5(NS, "session-D-verify-state")
FIX_A = uuid.uuid5(NS, "fix-A")
FIX_B = uuid.uuid5(NS, "fix-B")
FIX_C = uuid.uuid5(NS, "fix-C")
FIX_D = uuid.uuid5(NS, "fix-D")
CATEGORY_QA = uuid.uuid5(NS, "category-qa-fixtures")
TEMPLATE_QA = uuid.uuid5(NS, "template-qa-fixtures")
DRAFTED_SCRIPT = """\
# Phase 9 QA fixture — AI-drafted PowerShell to flush DNS and
# restart the FortiClient service. Not for production use.
ipconfig /flushdns
Restart-Service -Name "FortiSslvpnDaemon" -Force
Get-Service -Name "FortiSslvpnDaemon" | Format-Table -AutoSize
"""
TEMPLATE_BODY = """\
# Phase 9 QA fixture — canned template that the AI matches against.
param([string]$ServiceName = "FortiSslvpnDaemon")
Restart-Service -Name $ServiceName -Force
Get-Service -Name $ServiceName | Select-Object Status, Name
"""
async def main(reset: bool = False) -> None:
db_url = (
settings.ADMIN_DATABASE_URL
if hasattr(settings, "ADMIN_DATABASE_URL") and settings.ADMIN_DATABASE_URL
else settings.DATABASE_URL
)
engine = create_async_engine(db_url, echo=False)
now = datetime.now(timezone.utc)
async with engine.begin() as conn:
# ─── Locate the admin user + account ───────────────────────────
row = (
await conn.execute(
text(
"SELECT id, account_id FROM users WHERE email = :email LIMIT 1"
),
{"email": ADMIN_EMAIL},
)
).first()
if row is None:
print(
f"ERROR: user {ADMIN_EMAIL!r} not found. Run "
"`python -m scripts.seed_test_users` first.",
file=sys.stderr,
)
sys.exit(2)
user_id, account_id = row
if reset:
await conn.execute(
text(
"DELETE FROM session_suggested_fixes WHERE id = ANY(:ids)"
),
{"ids": [FIX_A, FIX_B, FIX_C, FIX_D]},
)
await conn.execute(
text("DELETE FROM ai_sessions WHERE id = ANY(:ids)"),
{"ids": [SESSION_A, SESSION_B, SESSION_C, SESSION_D]},
)
await conn.execute(
text("DELETE FROM script_templates WHERE id = :id"),
{"id": TEMPLATE_QA},
)
await conn.execute(
text("DELETE FROM script_categories WHERE id = :id"),
{"id": CATEGORY_QA},
)
# ─── Script category + template (for Session C) ────────────────
await conn.execute(
text(
"""
INSERT INTO script_categories (id, name, slug, sort_order, is_active, created_at, updated_at)
VALUES (:id, 'QA Fixtures', 'qa-fixtures', 999, true, :now, :now)
ON CONFLICT (id) DO NOTHING
"""
),
{"id": CATEGORY_QA, "now": now},
)
await conn.execute(
text(
"""
INSERT INTO script_templates (
id, category_id, account_id, created_by, name, slug,
description, script_body, language, parameters_schema,
default_values, validation_rules, tags, complexity,
requires_elevation, requires_modules, created_at, updated_at
)
VALUES (
:id, :cat_id, :acct_id, :user_id,
'QA Fixture: Restart Forti Service',
'qa-fixture-restart-forti-service',
'Phase 9 QA fixture template for TemplateMatchPanel testing.',
:body, 'powershell',
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '[]'::jsonb,
'beginner', false, '[]'::jsonb,
:now, :now
)
ON CONFLICT (id) DO NOTHING
"""
),
{
"id": TEMPLATE_QA,
"cat_id": CATEGORY_QA,
"acct_id": account_id,
"user_id": user_id,
"body": TEMPLATE_BODY,
"now": now,
},
)
# ─── 4 sessions ────────────────────────────────────────────────
# `canAct` in the chat header gates Resolve/Escalate on
# `messages.length >= 2`, so each fixture seeds two synthetic
# conversation messages — enough to enable the buttons that drive
# the Phase 9 surfaces.
seed_messages = (
'['
'{"role":"user","content":"QA fixture: see seed_phase9_qa_fixtures.py"},'
'{"role":"assistant","content":"This session is a Phase 9 QA fixture. The suggested fix below is pre-seeded — drive it from the UI."}'
']'
)
sessions = [
(SESSION_A, "Phase 9 QA — no-template path"),
(SESSION_B, "Phase 9 QA — drafted-script path"),
(SESSION_C, "Phase 9 QA — template-match path"),
(SESSION_D, "Phase 9 QA — verify state (Escalate intercept)"),
]
for sid, title in sessions:
await conn.execute(
text(
"""
INSERT INTO ai_sessions (
id, user_id, account_id, session_type, title,
intake_type, intake_content, status, confidence_tier,
confidence_score, conversation_messages,
total_input_tokens, total_output_tokens, step_count,
is_branching, state_version,
handoff_count, total_active_seconds, total_parked_seconds,
created_at, updated_at
)
VALUES (
:id, :user_id, :acct_id, 'chat', :title,
'free_text', '{"text": "QA fixture session"}'::jsonb,
'active', 'discovery',
0.0, (:msgs)::jsonb,
0, 0, 0,
false, 0,
0, 0, 0,
:now, :now
)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
status = EXCLUDED.status,
conversation_messages = EXCLUDED.conversation_messages,
updated_at = EXCLUDED.updated_at
"""
),
{
"id": sid,
"user_id": user_id,
"acct_id": account_id,
"title": title,
"msgs": seed_messages,
"now": now,
},
)
# ─── 4 suggested fixes ─────────────────────────────────────────
# Fix A — no template, no draft → ChatTabStrip + ScriptBuilderTab
await _upsert_fix(
conn, fix_id=FIX_A, session_id=SESSION_A, account_id=account_id,
title="Restart the FortiClient daemon and flush DNS",
description=(
"Error -8 on FortiClient SSL VPN typically clears after a "
"service restart on the endpoint. No matching template; "
"no AI draft yet — engineer should choose Build Template "
"or One-Off in the Script Builder tab."
),
confidence_pct=72,
script_template_id=None,
ai_drafted_script=None,
status="proposed",
applied_at=None,
now=now,
)
# Fix B — drafted script, no template → InlineNoTemplateDialog
await _upsert_fix(
conn, fix_id=FIX_B, session_id=SESSION_B, account_id=account_id,
title="Run AI-drafted PowerShell to recover SSL VPN",
description=(
"AI drafted a session-specific script because no library "
"template matched. Inline dialog should offer Save-as-template, "
"Run-once, or Discard."
),
confidence_pct=68,
script_template_id=None,
ai_drafted_script=DRAFTED_SCRIPT,
status="proposed",
applied_at=None,
now=now,
)
# Fix C — template match → TemplateMatchPanel
await _upsert_fix(
conn, fix_id=FIX_C, session_id=SESSION_C, account_id=account_id,
title="Match: QA Fixture Restart Forti Service",
description=(
"AI matched an existing library template. The match panel "
"should render with the parameterization preview and an "
"explicit 'I ran this' action."
),
confidence_pct=88,
script_template_id=TEMPLATE_QA,
ai_drafted_script=None,
status="proposed",
applied_at=None,
now=now,
)
# Fix D — applied_at set, status='proposed' → verify state.
# Hitting Escalate from this state opens EscalateInterceptDialog.
await _upsert_fix(
conn, fix_id=FIX_D, session_id=SESSION_D, account_id=account_id,
title="Verifying: post-apply tunnel reconnect",
description=(
"Engineer marked the fix as Applied; we're now in the "
"verify window. Clicking Escalate from here should open "
"the EscalateInterceptDialog with the four outcome choices "
"(worked / didn't / partial / never-applied)."
),
confidence_pct=80,
script_template_id=None,
ai_drafted_script=DRAFTED_SCRIPT,
status="proposed",
applied_at=now - timedelta(minutes=2),
now=now,
)
await engine.dispose()
print()
print("=" * 64)
print(" Phase 9 QA fixtures ready.")
print("=" * 64)
print()
print(f" Sign in as : {ADMIN_EMAIL}")
print(f" Then visit : http://docker-01:5173/pilot")
print(f" Pick from the History sidebar:")
print(f" A. Phase 9 QA — no-template path (ChatTabStrip + ScriptBuilderTab)")
print(f" B. Phase 9 QA — drafted-script path (InlineNoTemplateDialog)")
print(f" C. Phase 9 QA — template-match path (TemplateMatchPanel)")
print(f" D. Phase 9 QA — verify state (EscalateInterceptDialog)")
print()
print(f" Re-run with --reset to wipe and recreate.")
print()
async def _upsert_fix(
conn,
*,
fix_id: uuid.UUID,
session_id: uuid.UUID,
account_id: uuid.UUID,
title: str,
description: str,
confidence_pct: int,
script_template_id: uuid.UUID | None,
ai_drafted_script: str | None,
status: str,
applied_at: datetime | None,
now: datetime,
) -> None:
await conn.execute(
text(
"""
INSERT INTO session_suggested_fixes (
id, session_id, account_id, title, description,
confidence_pct, script_template_id, ai_drafted_script,
status, applied_at, created_at
)
VALUES (
:id, :sid, :acct, :title, :desc,
:conf, :tmpl, :draft,
:status, :applied, :now
)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
confidence_pct = EXCLUDED.confidence_pct,
script_template_id = EXCLUDED.script_template_id,
ai_drafted_script = EXCLUDED.ai_drafted_script,
status = EXCLUDED.status,
applied_at = EXCLUDED.applied_at,
superseded_at = NULL
"""
),
{
"id": fix_id,
"sid": session_id,
"acct": account_id,
"title": title,
"desc": description,
"conf": confidence_pct,
"tmpl": script_template_id,
"draft": ai_drafted_script,
"status": status,
"applied": applied_at,
"now": now,
},
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Seed Phase 9 QA fixtures.")
parser.add_argument(
"--reset",
action="store_true",
help="Delete and recreate the fixtures.",
)
args = parser.parse_args()
asyncio.run(main(reset=args.reset))

View File

@@ -161,8 +161,8 @@ async def main() -> None:
if cfg["plan"] is not None:
await conn.execute(
text("""
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
VALUES (:id, :aid, :plan, 'active', :now, :now)
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
VALUES (:id, :aid, :plan, 'active', false, :now, :now)
"""),
{"id": uuid.uuid4(), "aid": account_id, "plan": cfg["plan"], "now": now},
)

View File

@@ -41,7 +41,7 @@ export function EscalateInterceptDialog({
<div
role="dialog"
aria-label="Capture fix outcome before escalating"
className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]"
className="absolute top-full mt-2 right-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]"
>
{!partialStep ? (
<>

View File

@@ -9,7 +9,7 @@
* Kind switches the labels, button colors, and confirm-CTA text — the
* underlying mechanics (preview fetch + edit + post) are identical.
*/
import { useState, useEffect } from 'react'
import { useRef, useState, useEffect } from 'react'
import { Loader2, RefreshCw, X, FileText, Pencil, Check, ArrowUpRight } from 'lucide-react'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { cn } from '@/lib/utils'
@@ -43,6 +43,7 @@ export function ResolutionNotePreview({
const [refreshing, setRefreshing] = useState(false)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
const popoverRef = useRef<HTMLDivElement>(null)
// Keep the draft textarea in sync whenever fresh markdown arrives and we
// aren't in the middle of editing. Once the engineer edits, their changes
@@ -53,6 +54,15 @@ export function ResolutionNotePreview({
}
}, [preview?.markdown, editing])
// The popover renders at the bottom of TaskLane's scrollable region, which
// can leave it below the fold on smaller viewports. Scroll it into view
// whenever it opens so the engineer sees their preview immediately.
useEffect(() => {
if (open && popoverRef.current) {
popoverRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, [open])
if (!open) return null
const label = kind === 'resolve' ? 'Resolution note' : 'Escalation handoff package'
@@ -73,7 +83,7 @@ export function ResolutionNotePreview({
}
return (
<div className="rounded-lg border border-default bg-elevated/30 mx-3 mb-3 overflow-hidden shadow-lg">
<div ref={popoverRef} className="rounded-lg border border-default bg-elevated/30 mx-3 mb-3 overflow-hidden shadow-lg">
<div className="flex items-center justify-between px-3 py-2 border-b border-default bg-bg-page">
<div className="flex items-center gap-2">
<KindIcon size={13} className={kind === 'resolve' ? 'text-success' : 'text-warning'} />

View File

@@ -552,7 +552,11 @@ export default function AssistantChatPage() {
const handleApplyFix = useCallback(() => {
if (!activeFix) return
if (activeFix.script_template_id) {
setScriptPanelOpen(true) // existing TemplateMatchPanel flow in task lane
// TemplateMatchPanel is mounted inside TaskLane.bottomSlot, so the
// lane must be visible for the panel to render. On fresh sessions
// (no questions/facts) the lane defaults closed, so we open it here.
setShowTaskLane(true)
setScriptPanelOpen(true)
return
}
if (activeFix.ai_drafted_script) {