From b44c9797d9d7e352ab066b5a7e7163c450788ad3 Mon Sep 17 00:00:00 2001 From: Max Ritter Date: Tue, 24 Feb 2026 10:32:34 +0100 Subject: [PATCH 1/4] feat: add bugfix spec workflow, spec type detection, and viewer enhancements Introduce /spec-bugfix-plan command for structured bug fix planning with root cause analysis. Spec dispatcher now auto-detects task type (feature vs bugfix) and routes accordingly. Console viewer shows spec type badges, extracted SpecHeaderCard component, and updated plan reader to parse Type field. Installer adds property-based testing tools. Documentation and workflow rules updated to reflect the dual-mode spec system. --- README.md | 9 +- .../http/routes/utils/planFileReader.ts | 81 ++- console/src/ui/viewer/hooks/useStats.ts | 1 + .../ui/viewer/views/Dashboard/PlanStatus.tsx | 6 + .../ui/viewer/views/Spec/SpecHeaderCard.tsx | 164 ++++++ console/src/ui/viewer/views/Spec/index.tsx | 325 +++++------- .../tests/worker/plan-stats-endpoint.test.ts | 6 + docs/site/index.html | 2 +- docs/site/src/components/WorkflowSteps.tsx | 13 +- docs/site/src/pages/docs/QuickModeSection.tsx | 23 +- docs/site/src/pages/docs/SpecSection.tsx | 79 ++- installer/steps/dependencies.py | 31 ++ installer/steps/finalize.py | 2 +- .../tests/unit/steps/test_dependencies.py | 65 +++ launcher/statusline/formatter.py | Bin 17692 -> 18012 bytes .../tests/unit/statusline/test_formatter.py | Bin 42178 -> 46040 bytes launcher/tests/unit/test_worktree.py | Bin 38498 -> 45083 bytes launcher/worktree.py | Bin 14725 -> 16173 bytes pilot/commands/spec-bugfix-plan.md | 495 ++++++++++++++++++ pilot/commands/spec-plan.md | 3 + pilot/commands/spec.md | 131 +++-- pilot/rules/task-and-workflow.md | 48 +- pilot/scripts/mcp-server.cjs | 2 +- pilot/scripts/worker-service.cjs | 200 +++---- pilot/settings.json | 4 +- pilot/ui/viewer-bundle.js | 90 ++-- pilot/ui/viewer.css | 2 +- 27 files changed, 1357 insertions(+), 425 deletions(-) create mode 100644 console/src/ui/viewer/views/Spec/SpecHeaderCard.tsx create mode 100644 pilot/commands/spec-bugfix-plan.md diff --git a/README.md b/README.md index 42fd9855..1a0d13e7 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ After installation, run `pilot` or `ccp` in your project folder to start Claude 8-step installer with progress tracking, rollback on failure, and idempotent re-runs: 1. **Prerequisites** — Checks Homebrew, Node.js, Python 3.12+, uv, git -2. **Dependencies** — Installs Vexor, playwright-cli, Claude Code +2. **Dependencies** — Installs Vexor, playwright-cli, Claude Code, property-based testing tools 3. **Shell integration** — Auto-configures bash, fish, and zsh with `pilot` alias 4. **Config & Claude files** — Sets up `.claude/` plugin, rules, commands, hooks, MCP servers 5. **VS Code extensions** — Installs recommended extensions for your stack @@ -179,11 +179,12 @@ pilot ### /spec — Spec-Driven Development -Best for complex features, refactoring, or when you want to review a plan before implementation: +Best for features, bug fixes, refactoring, or when you want to review a plan before implementation. Auto-detects whether the task is a feature or bug fix and adapts the planning flow accordingly. ```bash pilot > /spec "Add user authentication with OAuth and JWT tokens" +> /spec "Fix the crash when deleting nodes with two children" ``` ``` @@ -251,11 +252,11 @@ Pilot uses the right model for each phase — Opus where reasoning quality matte ### Quick Mode -Just chat. No plan file, no approval gate. All quality hooks and TDD enforcement still apply. +Just chat. No plan file, no approval gate. All quality hooks and TDD enforcement still apply. Best for small tasks, exploration, and quick questions. ```bash pilot -> Fix the null pointer bug in user.py +> Add a loading spinner to the submit button ``` ### /learn — Online Learning diff --git a/console/src/services/worker/http/routes/utils/planFileReader.ts b/console/src/services/worker/http/routes/utils/planFileReader.ts index 29ebcc7c..eb16bc08 100644 --- a/console/src/services/worker/http/routes/utils/planFileReader.ts +++ b/console/src/services/worker/http/routes/utils/planFileReader.ts @@ -17,6 +17,7 @@ export interface PlanInfo { iterations: number; approved: boolean; worktree: boolean; + specType?: "Feature" | "Bugfix"; filePath: string; modifiedAt: string; } @@ -39,13 +40,21 @@ export function parsePlanContent( const total = completedTasks + remainingTasks; const approvedMatch = content.match(/^Approved:\s*(\w+)/m); - const approved = approvedMatch ? approvedMatch[1].toLowerCase() === "yes" : false; + const approved = approvedMatch + ? approvedMatch[1].toLowerCase() === "yes" + : false; const iterMatch = content.match(/^Iterations:\s*(\d+)/m); const iterations = iterMatch ? parseInt(iterMatch[1], 10) : 0; const worktreeMatch = content.match(/^Worktree:\s*(\w+)/m); - const worktree = worktreeMatch ? worktreeMatch[1].toLowerCase() !== "no" : true; + const worktree = worktreeMatch + ? worktreeMatch[1].toLowerCase() !== "no" + : true; + + const typeMatch = content.match(/^Type:\s*(\w+)/m); + const specType = + typeMatch?.[1] === "Bugfix" ? ("Bugfix" as const) : undefined; let phase: "plan" | "implement" | "verify"; if (status === "PENDING" && !approved) { @@ -70,6 +79,7 @@ export function parsePlanContent( iterations, approved, worktree, + ...(specType && { specType }), filePath, modifiedAt: modifiedAt.toISOString(), }; @@ -95,8 +105,7 @@ export function getWorktreePlansDirs(projectRoot: string): string[] { dirs.push(plansDir); } } - } catch { - } + } catch {} return dirs; } @@ -115,13 +124,23 @@ function scanPlansDir(plansDir: string): PlanInfo[] { const filePath = path.join(plansDir, planFile); const stat = statSync(filePath); const content = readFileSync(filePath, "utf-8"); - const planInfo = parsePlanContent(content, planFile, filePath, stat.mtime); + const planInfo = parsePlanContent( + content, + planFile, + filePath, + stat.mtime, + ); if (planInfo) { plans.push(planInfo); } } } catch (error) { - logger.error("HTTP", "Failed to read plans from directory", { plansDir }, error as Error); + logger.error( + "HTTP", + "Failed to read plans from directory", + { plansDir }, + error as Error, + ); } return plans; } @@ -162,14 +181,24 @@ export function getActivePlans(projectRoot: string): PlanInfo[] { } const content = readFileSync(filePath, "utf-8"); - const planInfo = parsePlanContent(content, planFile, filePath, stat.mtime); + const planInfo = parsePlanContent( + content, + planFile, + filePath, + stat.mtime, + ); if (planInfo && planInfo.status !== "VERIFIED") { activePlans.push(planInfo); } } } catch (error) { - logger.error("HTTP", "Failed to read active plans", { plansDir }, error as Error); + logger.error( + "HTTP", + "Failed to read active plans", + { plansDir }, + error as Error, + ); } } @@ -184,7 +213,10 @@ export function getAllPlans(projectRoot: string): PlanInfo[] { } return allPlans - .sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()) + .sort( + (a, b) => + new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(), + ) .slice(0, 10); } @@ -195,7 +227,10 @@ export function getActiveSpecs(projectRoot: string): PlanInfo[] { allPlans.push(...scanPlansDir(plansDir)); } - return allPlans.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + return allPlans.sort( + (a, b) => + new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(), + ); } export function getPlanStats(projectRoot: string): { @@ -217,14 +252,22 @@ export function getPlanStats(projectRoot: string): { if (allPlans.length === 0) { return { - totalSpecs: 0, verified: 0, inProgress: 0, pending: 0, - avgIterations: 0, totalTasksCompleted: 0, totalTasks: 0, - completionTimeline: [], recentlyVerified: [], + totalSpecs: 0, + verified: 0, + inProgress: 0, + pending: 0, + avgIterations: 0, + totalTasksCompleted: 0, + totalTasks: 0, + completionTimeline: [], + recentlyVerified: [], }; } const verified = allPlans.filter((p) => p.status === "VERIFIED"); - const inProgress = allPlans.filter((p) => (p.status === "PENDING" && p.approved) || p.status === "COMPLETE"); + const inProgress = allPlans.filter( + (p) => (p.status === "PENDING" && p.approved) || p.status === "COMPLETE", + ); const pending = allPlans.filter((p) => p.status === "PENDING" && !p.approved); const verifiedIter = verified.reduce((sum, p) => sum + p.iterations, 0); const totalTasksCompleted = allPlans.reduce((sum, p) => sum + p.completed, 0); @@ -240,7 +283,10 @@ export function getPlanStats(projectRoot: string): { .map(([date, count]) => ({ date, count })); const recentlyVerified = verified - .sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()) + .sort( + (a, b) => + new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(), + ) .slice(0, 5) .map((p) => ({ name: p.name, verifiedAt: p.modifiedAt })); @@ -249,7 +295,10 @@ export function getPlanStats(projectRoot: string): { verified: verified.length, inProgress: inProgress.length, pending: pending.length, - avgIterations: verified.length > 0 ? Math.round((verifiedIter / verified.length) * 10) / 10 : 0, + avgIterations: + verified.length > 0 + ? Math.round((verifiedIter / verified.length) * 10) / 10 + : 0, totalTasksCompleted, totalTasks, completionTimeline, diff --git a/console/src/ui/viewer/hooks/useStats.ts b/console/src/ui/viewer/hooks/useStats.ts index 64691929..d3897f0b 100644 --- a/console/src/ui/viewer/hooks/useStats.ts +++ b/console/src/ui/viewer/hooks/useStats.ts @@ -57,6 +57,7 @@ interface PlanInfo { iterations: number; approved: boolean; worktree: boolean; + specType?: "Feature" | "Bugfix"; filePath?: string; } diff --git a/console/src/ui/viewer/views/Dashboard/PlanStatus.tsx b/console/src/ui/viewer/views/Dashboard/PlanStatus.tsx index 31cfb6ee..17e68b0f 100644 --- a/console/src/ui/viewer/views/Dashboard/PlanStatus.tsx +++ b/console/src/ui/viewer/views/Dashboard/PlanStatus.tsx @@ -9,6 +9,7 @@ interface PlanInfo { iterations: number; approved: boolean; worktree: boolean; + specType?: "Feature" | "Bugfix"; filePath?: string; } @@ -38,6 +39,11 @@ function PlanRow({ plan }: { plan: PlanInfo }) {
{plan.name} + {plan.specType === "Bugfix" && ( + + bugfix + + )}
t.completed).length; + const totalCount = parsed.tasks.length; + const progressPct = totalCount > 0 ? (completedCount / totalCount) * 100 : 0; + + return ( + + +
+
+

{parsed.title}

+ {parsed.goal && ( +

{parsed.goal}

+ )} +
+ + + {config.label} + +
+ + {/* Progress bar */} +
+
+ Progress + + {completedCount} / {totalCount} tasks + +
+ +
+ + {/* Task Checklist */} +
+ {parsed.tasks.map((task) => ( +
+
+ {task.completed ? ( + + ) : ( + + {task.number} + + )} +
+ + Task {task.number}: {task.title} + +
+ ))} +
+ + {/* Metadata row */} +
+ {spec.specType === "Bugfix" && ( + + Bugfix + + )} + {spec.iterations > 0 && ( +
+ + + {spec.iterations} iteration + {spec.iterations > 1 ? "s" : ""} + +
+ )} + {!spec.approved && spec.status === "PENDING" && ( + + Awaiting Approval + + )} + {spec.worktree ? ( +
+ + Worktree +
+ ) : ( +
+ + Direct +
+ )} + {spec.modifiedAt && ( +
+ + + {new Date(spec.modifiedAt).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + +
+ )} +
+ + {spec.filePath.split("/").pop()} +
+
+
+
+ ); +} diff --git a/console/src/ui/viewer/views/Spec/index.tsx b/console/src/ui/viewer/views/Spec/index.tsx index 442d80c0..b690048b 100644 --- a/console/src/ui/viewer/views/Spec/index.tsx +++ b/console/src/ui/viewer/views/Spec/index.tsx @@ -1,19 +1,29 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { Card, CardBody, Badge, Icon, Button, Spinner, Progress, Tooltip } from '../../components/ui'; -import { SpecContent } from './SpecContent'; -import { WorktreePanel } from './WorktreePanel'; -import { TIMING } from '../../constants/timing'; -import { useProject } from '../../context'; +import { useState, useEffect, useCallback, useRef } from "react"; +import { + Card, + CardBody, + Badge, + Icon, + Button, + Spinner, + Tooltip, +} from "../../components/ui"; +import { SpecContent } from "./SpecContent"; +import { SpecHeaderCard } from "./SpecHeaderCard"; +import { WorktreePanel } from "./WorktreePanel"; +import { TIMING } from "../../constants/timing"; +import { useProject } from "../../context"; interface PlanInfo { name: string; - status: 'PENDING' | 'COMPLETE' | 'VERIFIED'; + status: "PENDING" | "COMPLETE" | "VERIFIED"; completed: number; total: number; - phase: 'plan' | 'implement' | 'verify'; + phase: "plan" | "implement" | "verify"; iterations: number; approved: boolean; worktree: boolean; + specType?: "Feature" | "Bugfix"; filePath: string; modifiedAt: string; } @@ -25,45 +35,43 @@ interface PlanContent { filePath: string; } -interface ParsedTask { - number: number; - title: string; - completed: boolean; -} - interface ParsedPlan { title: string; goal: string; - tasks: ParsedTask[]; + tasks: Array<{ number: number; title: string; completed: boolean }>; implementationSection: string; } -const statusConfig = { - PENDING: { color: 'warning', icon: 'lucide:clock', label: 'In Progress' }, - COMPLETE: { color: 'info', icon: 'lucide:check-circle', label: 'Complete' }, - VERIFIED: { color: 'success', icon: 'lucide:shield-check', label: 'Verified' }, +const statusIcons = { + PENDING: "lucide:clock", + COMPLETE: "lucide:check-circle", + VERIFIED: "lucide:shield-check", } as const; function parsePlanContent(content: string): ParsedPlan { const titleMatch = content.match(/^#\s+(.+)$/m); - const title = titleMatch ? titleMatch[1].replace(' Implementation Plan', '') : 'Untitled'; + const title = titleMatch + ? titleMatch[1].replace(" Implementation Plan", "") + : "Untitled"; const goalMatch = content.match(/\*\*Goal:\*\*\s*(.+?)(?:\n|$)/); - const goal = goalMatch ? goalMatch[1] : ''; + const goal = goalMatch ? goalMatch[1] : ""; - const tasks: ParsedTask[] = []; + const tasks: ParsedPlan["tasks"] = []; const taskRegex = /^- \[(x| )\] Task (\d+):\s*(.+)$/gm; let match; while ((match = taskRegex.exec(content)) !== null) { tasks.push({ number: parseInt(match[2], 10), title: match[3], - completed: match[1] === 'x', + completed: match[1] === "x", }); } - const implMatch = content.match(/## Implementation Tasks\n([\s\S]*?)(?=\n## [^#]|$)/); - const implementationSection = implMatch ? implMatch[1].trim() : ''; + const implMatch = content.match( + /## Implementation Tasks\n([\s\S]*?)(?=\n## [^#]|$)/, + ); + const implementationSection = implMatch ? implMatch[1].trim() : ""; return { title, goal, tasks, implementationSection }; } @@ -78,7 +86,9 @@ export function SpecView() { const [error, setError] = useState(null); const [isDeleting, setIsDeleting] = useState(false); - const projectParam = selectedProject ? `?project=${encodeURIComponent(selectedProject)}` : ''; + const projectParam = selectedProject + ? `?project=${encodeURIComponent(selectedProject)}` + : ""; const lastProjectRef = useRef(selectedProject); if (lastProjectRef.current !== selectedProject) { @@ -96,75 +106,78 @@ export function SpecView() { setSpecs(data.specs || []); if (data.specs?.length > 0 && !selectedSpec) { - const active = data.specs.find((s: PlanInfo) => s.status === 'PENDING' || s.status === 'COMPLETE'); + const active = data.specs.find( + (s: PlanInfo) => s.status === "PENDING" || s.status === "COMPLETE", + ); setSelectedSpec(active ? active.filePath : data.specs[0].filePath); } } catch (err) { - setError('Failed to load specs'); - console.error('Failed to load specs:', err); + setError("Failed to load specs"); + console.error("Failed to load specs:", err); } finally { setIsLoading(false); } }, [selectedSpec, projectParam]); - const loadContent = useCallback(async (filePath: string, background = false) => { - if (!background) { - setIsLoadingContent(true); - } - setError(null); - try { - const res = await fetch(`/api/plan/content?path=${encodeURIComponent(filePath)}${selectedProject ? `&project=${encodeURIComponent(selectedProject)}` : ''}`); - if (!res.ok) { - throw new Error('Failed to load spec content'); + const loadContent = useCallback( + async (filePath: string, background = false) => { + if (!background) setIsLoadingContent(true); + setError(null); + try { + const res = await fetch( + `/api/plan/content?path=${encodeURIComponent(filePath)}${selectedProject ? `&project=${encodeURIComponent(selectedProject)}` : ""}`, + ); + if (!res.ok) throw new Error("Failed to load spec content"); + setContent(await res.json()); + } catch (err) { + setError("Failed to load spec content"); + console.error("Failed to load spec content:", err); + } finally { + if (!background) setIsLoadingContent(false); } - const data = await res.json(); - setContent(data); - } catch (err) { - setError('Failed to load spec content'); - console.error('Failed to load spec content:', err); - } finally { - if (!background) { - setIsLoadingContent(false); - } - } - }, [selectedProject]); + }, + [selectedProject], + ); - const deleteSpec = useCallback(async (filePath: string) => { - if (!confirm(`Delete spec "${filePath.split('/').pop()}"? This cannot be undone.`)) { - return; - } - setIsDeleting(true); - try { - const res = await fetch(`/api/plan?path=${encodeURIComponent(filePath)}`, { method: 'DELETE' }); - if (!res.ok) { - throw new Error('Failed to delete spec'); + const deleteSpec = useCallback( + async (filePath: string) => { + if ( + !confirm( + `Delete spec "${filePath.split("/").pop()}"? This cannot be undone.`, + ) + ) + return; + setIsDeleting(true); + try { + const res = await fetch( + `/api/plan?path=${encodeURIComponent(filePath)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Failed to delete spec"); + setSelectedSpec(null); + setContent(null); + await loadSpecs(); + } catch (err) { + setError("Failed to delete spec"); + console.error("Failed to delete spec:", err); + } finally { + setIsDeleting(false); } - setSelectedSpec(null); - setContent(null); - await loadSpecs(); - } catch (err) { - setError('Failed to delete spec'); - console.error('Failed to delete spec:', err); - } finally { - setIsDeleting(false); - } - }, [loadSpecs]); + }, + [loadSpecs], + ); useEffect(() => { loadSpecs(); const interval = setInterval(() => { loadSpecs(); - if (selectedSpec) { - loadContent(selectedSpec, true); - } + if (selectedSpec) loadContent(selectedSpec, true); }, TIMING.SPEC_REFRESH_INTERVAL_MS); return () => clearInterval(interval); }, [loadSpecs, loadContent, selectedSpec]); useEffect(() => { - if (selectedSpec) { - loadContent(selectedSpec); - } + if (selectedSpec) loadContent(selectedSpec); }, [selectedSpec, loadContent]); if (isLoading) { @@ -181,10 +194,18 @@ export function SpecView() {
- +

No Active Specs

- Use /spec in Claude Pilot to start a spec-driven development workflow. + Use{" "} + + /spec + {" "} + in Claude Pilot to start a spec-driven development workflow.

@@ -193,14 +214,12 @@ export function SpecView() { ); } - const activeSpecs = specs.filter(s => s.status === 'PENDING' || s.status === 'COMPLETE'); - const archivedSpecs = specs.filter(s => s.status === 'VERIFIED'); - const currentSpec = specs.find(s => s.filePath === selectedSpec); - const config = currentSpec ? statusConfig[currentSpec.status] : null; + const activeSpecs = specs.filter( + (s) => s.status === "PENDING" || s.status === "COMPLETE", + ); + const archivedSpecs = specs.filter((s) => s.status === "VERIFIED"); + const currentSpec = specs.find((s) => s.filePath === selectedSpec); const parsed = content ? parsePlanContent(content.content) : null; - const completedCount = parsed?.tasks.filter(t => t.completed).length || 0; - const totalCount = parsed?.tasks.length || 0; - const progressPct = totalCount > 0 ? (completedCount / totalCount) * 100 : 0; return (
@@ -210,7 +229,10 @@ export function SpecView() { {/* Active plan tabs */} {activeSpecs.length > 0 && ( -
+
{activeSpecs.map((spec) => { const isActive = selectedSpec === spec.filePath; return ( @@ -220,19 +242,28 @@ export function SpecView() { aria-selected={isActive} className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors cursor-pointer flex items-center gap-1.5 ${ isActive - ? 'bg-primary/10 border-primary/30 text-primary' - : 'bg-base-200/60 border-base-300/50 text-base-content/70 hover:bg-base-200' + ? "bg-primary/10 border-primary/30 text-primary" + : "bg-base-200/60 border-base-300/50 text-base-content/70 hover:bg-base-200" }`} onClick={() => setSelectedSpec(spec.filePath)} > {spec.name} + {spec.specType === "Bugfix" && ( + + bugfix + + )} {spec.total > 0 && ( - {spec.completed}/{spec.total} + + {spec.completed}/{spec.total} + )} ); @@ -244,7 +275,7 @@ export function SpecView() { {archivedSpecs.length > 0 && (