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/PlanRoutes.ts b/console/src/services/worker/http/routes/PlanRoutes.ts index 274f73f1..83e053b6 100644 --- a/console/src/services/worker/http/routes/PlanRoutes.ts +++ b/console/src/services/worker/http/routes/PlanRoutes.ts @@ -42,11 +42,17 @@ function isValidPlanPath(projectRoot: string, resolvedPath: string): boolean { if (!resolvedPath.endsWith(".md")) return false; const normalizedRoot = path.resolve(projectRoot); const mainPlansDir = path.join(normalizedRoot, "docs", "plans"); - if (resolvedPath.startsWith(mainPlansDir + path.sep) || resolvedPath.startsWith(mainPlansDir + "/")) { + if ( + resolvedPath.startsWith(mainPlansDir + path.sep) || + resolvedPath.startsWith(mainPlansDir + "/") + ) { return true; } const worktreesDir = path.join(normalizedRoot, ".worktrees"); - if (resolvedPath.startsWith(worktreesDir) && resolvedPath.includes("/docs/plans/")) { + if ( + resolvedPath.startsWith(worktreesDir) && + resolvedPath.includes("/docs/plans/") + ) { return true; } return false; @@ -62,10 +68,16 @@ export class PlanRoutes extends BaseRouteHandler { this.sseBroadcaster = sseBroadcaster ?? null; } - private static VALID_PLAN_STATUSES = new Set(["PENDING", "COMPLETE", "VERIFIED"]); + private static VALID_PLAN_STATUSES = new Set([ + "PENDING", + "COMPLETE", + "VERIFIED", + ]); private isValidPlanStatus(status: unknown): status is PlanInfo["status"] { - return typeof status === "string" && PlanRoutes.VALID_PLAN_STATUSES.has(status); + return ( + typeof status === "string" && PlanRoutes.VALID_PLAN_STATUSES.has(status) + ); } setupRoutes(app: express.Application): void { @@ -77,208 +89,286 @@ export class PlanRoutes extends BaseRouteHandler { app.get("/api/plans/stats", this.handleGetPlanStats.bind(this)); app.get("/api/git", this.handleGetGitInfo.bind(this)); - app.post("/api/sessions/:sessionDbId/plan", this.handleAssociatePlan.bind(this)); + app.post( + "/api/sessions/:sessionDbId/plan", + this.handleAssociatePlan.bind(this), + ); app.post( "/api/sessions/by-content-id/:contentSessionId/plan", this.handleAssociatePlanByContentId.bind(this), ); - app.get("/api/sessions/:sessionDbId/plan", this.handleGetSessionPlan.bind(this)); + app.get( + "/api/sessions/:sessionDbId/plan", + this.handleGetSessionPlan.bind(this), + ); app.get( "/api/sessions/by-content-id/:contentSessionId/plan", this.handleGetSessionPlanByContentId.bind(this), ); - app.delete("/api/sessions/:sessionDbId/plan", this.handleClearSessionPlan.bind(this)); - app.put("/api/sessions/:sessionDbId/plan/status", this.handleUpdatePlanStatus.bind(this)); + app.delete( + "/api/sessions/:sessionDbId/plan", + this.handleClearSessionPlan.bind(this), + ); + app.put( + "/api/sessions/:sessionDbId/plan/status", + this.handleUpdatePlanStatus.bind(this), + ); } - private handleGetPlanStats = this.wrapHandler((req: Request, res: Response): void => { - const project = req.query.project as string | undefined; - const projectRoot = resolveProjectRoot(this.dbManager, project); - res.json(getPlanStats(projectRoot)); - }); - - private handleGetActivePlan = this.wrapHandler((req: Request, res: Response): void => { - const project = req.query.project as string | undefined; - const projectRoot = resolveProjectRoot(this.dbManager, project); - const plans = getActivePlans(projectRoot); - res.json({ active: plans.length > 0, plans, plan: plans[0] || null }); - }); - - private handleGetAllPlans = this.wrapHandler((req: Request, res: Response): void => { - const project = req.query.project as string | undefined; - const projectRoot = resolveProjectRoot(this.dbManager, project); - res.json({ plans: getAllPlans(projectRoot) }); - }); - - private handleGetGitInfo = this.wrapHandler((req: Request, res: Response): void => { - const project = req.query.project as string | undefined; - const projectRoot = resolveProjectRoot(this.dbManager, project); - res.json(getGitInfo(projectRoot)); - }); - - private handleGetActiveSpecs = this.wrapHandler((req: Request, res: Response): void => { - const project = req.query.project as string | undefined; - const projectRoot = resolveProjectRoot(this.dbManager, project); - res.json({ specs: getActiveSpecs(projectRoot) }); - }); - - private handleGetPlanContent = this.wrapHandler((req: Request, res: Response): void => { - const project = req.query.project as string | undefined; - const projectRoot = resolveProjectRoot(this.dbManager, project); - const requestedPath = req.query.path as string | undefined; - - if (!requestedPath) { - const specs = getActiveSpecs(projectRoot); - if (specs.length === 0) { - res.status(404).json({ error: "No active specs found" }); + private handleGetPlanStats = this.wrapHandler( + (req: Request, res: Response): void => { + const project = req.query.project as string | undefined; + const projectRoot = resolveProjectRoot(this.dbManager, project); + res.json(getPlanStats(projectRoot)); + }, + ); + + private handleGetActivePlan = this.wrapHandler( + (req: Request, res: Response): void => { + const project = req.query.project as string | undefined; + const projectRoot = resolveProjectRoot(this.dbManager, project); + const plans = getActivePlans(projectRoot); + res.json({ active: plans.length > 0, plans, plan: plans[0] || null }); + }, + ); + + private handleGetAllPlans = this.wrapHandler( + (req: Request, res: Response): void => { + const project = req.query.project as string | undefined; + const projectRoot = resolveProjectRoot(this.dbManager, project); + res.json({ plans: getAllPlans(projectRoot) }); + }, + ); + + private handleGetGitInfo = this.wrapHandler( + (req: Request, res: Response): void => { + const project = req.query.project as string | undefined; + const projectRoot = resolveProjectRoot(this.dbManager, project); + res.json(getGitInfo(projectRoot)); + }, + ); + + private handleGetActiveSpecs = this.wrapHandler( + (req: Request, res: Response): void => { + const project = req.query.project as string | undefined; + const projectRoot = resolveProjectRoot(this.dbManager, project); + res.json({ specs: getActiveSpecs(projectRoot) }); + }, + ); + + private handleGetPlanContent = this.wrapHandler( + (req: Request, res: Response): void => { + const project = req.query.project as string | undefined; + const projectRoot = resolveProjectRoot(this.dbManager, project); + const requestedPath = req.query.path as string | undefined; + + if (!requestedPath) { + const specs = getActiveSpecs(projectRoot); + if (specs.length === 0) { + res.status(404).json({ error: "No active specs found" }); + return; + } + const firstSpec = specs[0]; + try { + const content = readFileSync(firstSpec.filePath, "utf-8"); + res.json({ + content, + name: firstSpec.name, + status: firstSpec.status, + filePath: firstSpec.filePath, + }); + } catch { + res.status(404).json({ error: "Plan file not found" }); + } return; } - const firstSpec = specs[0]; - try { - const content = readFileSync(firstSpec.filePath, "utf-8"); - res.json({ content, name: firstSpec.name, status: firstSpec.status, filePath: firstSpec.filePath }); - } catch { - res.status(404).json({ error: "Plan file not found" }); - } - return; - } - const resolvedPath = path.resolve(projectRoot, requestedPath); + const resolvedPath = path.resolve(projectRoot, requestedPath); - if (!isValidPlanPath(projectRoot, resolvedPath)) { - res.status(403).json({ error: "Access denied: path must be within docs/plans/ or .worktrees/*/docs/plans/" }); - return; - } + if (!isValidPlanPath(projectRoot, resolvedPath)) { + res + .status(403) + .json({ + error: + "Access denied: path must be within docs/plans/ or .worktrees/*/docs/plans/", + }); + return; + } - if (!existsSync(resolvedPath)) { - res.status(404).json({ error: "Plan not found" }); - return; - } + if (!existsSync(resolvedPath)) { + res.status(404).json({ error: "Plan not found" }); + return; + } - const content = readFileSync(resolvedPath, "utf-8"); - const fileName = path.basename(resolvedPath); - const stat = statSync(resolvedPath); - const planInfo = parsePlanContent(content, fileName, resolvedPath, stat.mtime); - - res.json({ - content, - name: planInfo?.name || fileName.replace(".md", ""), - status: planInfo?.status || "UNKNOWN", - filePath: resolvedPath, - }); - }); - - private handleDeletePlan = this.wrapHandler((req: Request, res: Response): void => { - const projectRoot = process.env.CLAUDE_PROJECT_ROOT || process.cwd(); - const requestedPath = req.query.path as string | undefined; - - if (!requestedPath) { - this.badRequest(res, "Missing path query parameter"); - return; - } + const content = readFileSync(resolvedPath, "utf-8"); + const fileName = path.basename(resolvedPath); + const stat = statSync(resolvedPath); + const planInfo = parsePlanContent( + content, + fileName, + resolvedPath, + stat.mtime, + ); + + res.json({ + content, + name: planInfo?.name || fileName.replace(".md", ""), + status: planInfo?.status || "UNKNOWN", + filePath: resolvedPath, + }); + }, + ); + + private handleDeletePlan = this.wrapHandler( + (req: Request, res: Response): void => { + const project = req.query.project as string | undefined; + const projectRoot = resolveProjectRoot(this.dbManager, project); + const requestedPath = req.query.path as string | undefined; + + if (!requestedPath) { + this.badRequest(res, "Missing path query parameter"); + return; + } - const resolvedPath = path.resolve(projectRoot, requestedPath); + const resolvedPath = path.resolve(projectRoot, requestedPath); - if (!isValidPlanPath(projectRoot, resolvedPath)) { - res.status(403).json({ error: "Access denied: path must be within docs/plans/ or .worktrees/*/docs/plans/" }); - return; - } + if (!isValidPlanPath(projectRoot, resolvedPath)) { + res + .status(403) + .json({ + error: + "Access denied: path must be within docs/plans/ or .worktrees/*/docs/plans/", + }); + return; + } - if (!existsSync(resolvedPath)) { - this.notFound(res, "Plan not found"); - return; - } + if (!existsSync(resolvedPath)) { + this.notFound(res, "Plan not found"); + return; + } - unlinkSync(resolvedPath); - res.json({ success: true }); - }); - - private handleAssociatePlan = this.wrapHandler((req: Request, res: Response): void => { - const sessionDbId = this.parseIntParam(req, res, "sessionDbId"); - if (sessionDbId === null) return; - if (!this.validateRequired(req, res, ["planPath", "status"])) return; - if (!this.isValidPlanStatus(req.body.status)) { - this.badRequest(res, `Invalid status: ${req.body.status}. Must be PENDING, COMPLETE, or VERIFIED`); - return; - } - const db = this.getDb(res); - if (!db) return; - - const result = associatePlan(db, sessionDbId, req.body.planPath, req.body.status); - this.broadcastPlanChange(); - res.json({ plan: result }); - }); - - private handleAssociatePlanByContentId = this.wrapHandler((req: Request, res: Response): void => { - const contentSessionId = req.params.contentSessionId; - if (!contentSessionId) { - this.badRequest(res, "Missing contentSessionId"); - return; - } - if (!this.validateRequired(req, res, ["planPath", "status"])) return; - if (!this.isValidPlanStatus(req.body.status)) { - this.badRequest(res, `Invalid status: ${req.body.status}. Must be PENDING, COMPLETE, or VERIFIED`); - return; - } - const db = this.getDb(res); - if (!db) return; - - const row = db.prepare("SELECT id FROM sdk_sessions WHERE content_session_id = ?").get(contentSessionId) as - | { id: number } - | null; - if (!row) { - this.notFound(res, "Session not found"); - return; - } + unlinkSync(resolvedPath); + res.json({ success: true }); + }, + ); + + private handleAssociatePlan = this.wrapHandler( + (req: Request, res: Response): void => { + const sessionDbId = this.parseIntParam(req, res, "sessionDbId"); + if (sessionDbId === null) return; + if (!this.validateRequired(req, res, ["planPath", "status"])) return; + if (!this.isValidPlanStatus(req.body.status)) { + this.badRequest( + res, + `Invalid status: ${req.body.status}. Must be PENDING, COMPLETE, or VERIFIED`, + ); + return; + } + const db = this.getDb(res); + if (!db) return; + + const result = associatePlan( + db, + sessionDbId, + req.body.planPath, + req.body.status, + ); + this.broadcastPlanChange(); + res.json({ plan: result }); + }, + ); + + private handleAssociatePlanByContentId = this.wrapHandler( + (req: Request, res: Response): void => { + const contentSessionId = req.params.contentSessionId; + if (!contentSessionId) { + this.badRequest(res, "Missing contentSessionId"); + return; + } + if (!this.validateRequired(req, res, ["planPath", "status"])) return; + if (!this.isValidPlanStatus(req.body.status)) { + this.badRequest( + res, + `Invalid status: ${req.body.status}. Must be PENDING, COMPLETE, or VERIFIED`, + ); + return; + } + const db = this.getDb(res); + if (!db) return; + + const row = db + .prepare("SELECT id FROM sdk_sessions WHERE content_session_id = ?") + .get(contentSessionId) as { id: number } | null; + if (!row) { + this.notFound(res, "Session not found"); + return; + } - const result = associatePlan(db, row.id, req.body.planPath, req.body.status); - this.broadcastPlanChange(); - res.json({ plan: result }); - }); - - private handleGetSessionPlan = this.wrapHandler((req: Request, res: Response): void => { - const sessionDbId = this.parseIntParam(req, res, "sessionDbId"); - if (sessionDbId === null) return; - const db = this.getDb(res); - if (!db) return; - res.json({ plan: getPlanForSession(db, sessionDbId) }); - }); - - private handleGetSessionPlanByContentId = this.wrapHandler((req: Request, res: Response): void => { - const contentSessionId = req.params.contentSessionId; - if (!contentSessionId) { - this.badRequest(res, "Missing contentSessionId"); - return; - } - const db = this.getDb(res); - if (!db) return; - res.json({ plan: getPlanByContentSessionId(db, contentSessionId) }); - }); - - private handleClearSessionPlan = this.wrapHandler((req: Request, res: Response): void => { - const sessionDbId = this.parseIntParam(req, res, "sessionDbId"); - if (sessionDbId === null) return; - const db = this.getDb(res); - if (!db) return; - clearPlanAssociation(db, sessionDbId); - this.broadcastPlanChange(); - res.json({ success: true }); - }); - - private handleUpdatePlanStatus = this.wrapHandler((req: Request, res: Response): void => { - const sessionDbId = this.parseIntParam(req, res, "sessionDbId"); - if (sessionDbId === null) return; - if (!this.validateRequired(req, res, ["status"])) return; - if (!this.isValidPlanStatus(req.body.status)) { - this.badRequest(res, `Invalid status: ${req.body.status}. Must be PENDING, COMPLETE, or VERIFIED`); - return; - } - const db = this.getDb(res); - if (!db) return; - updatePlanStatus(db, sessionDbId, req.body.status); - this.broadcastPlanChange(); - res.json({ plan: getPlanForSession(db, sessionDbId) }); - }); + const result = associatePlan( + db, + row.id, + req.body.planPath, + req.body.status, + ); + this.broadcastPlanChange(); + res.json({ plan: result }); + }, + ); + + private handleGetSessionPlan = this.wrapHandler( + (req: Request, res: Response): void => { + const sessionDbId = this.parseIntParam(req, res, "sessionDbId"); + if (sessionDbId === null) return; + const db = this.getDb(res); + if (!db) return; + res.json({ plan: getPlanForSession(db, sessionDbId) }); + }, + ); + + private handleGetSessionPlanByContentId = this.wrapHandler( + (req: Request, res: Response): void => { + const contentSessionId = req.params.contentSessionId; + if (!contentSessionId) { + this.badRequest(res, "Missing contentSessionId"); + return; + } + const db = this.getDb(res); + if (!db) return; + res.json({ plan: getPlanByContentSessionId(db, contentSessionId) }); + }, + ); + + private handleClearSessionPlan = this.wrapHandler( + (req: Request, res: Response): void => { + const sessionDbId = this.parseIntParam(req, res, "sessionDbId"); + if (sessionDbId === null) return; + const db = this.getDb(res); + if (!db) return; + clearPlanAssociation(db, sessionDbId); + this.broadcastPlanChange(); + res.json({ success: true }); + }, + ); + + private handleUpdatePlanStatus = this.wrapHandler( + (req: Request, res: Response): void => { + const sessionDbId = this.parseIntParam(req, res, "sessionDbId"); + if (sessionDbId === null) return; + if (!this.validateRequired(req, res, ["status"])) return; + if (!this.isValidPlanStatus(req.body.status)) { + this.badRequest( + res, + `Invalid status: ${req.body.status}. Must be PENDING, COMPLETE, or VERIFIED`, + ); + return; + } + const db = this.getDb(res); + if (!db) return; + updatePlanStatus(db, sessionDbId, req.body.status); + this.broadcastPlanChange(); + res.json({ plan: getPlanForSession(db, sessionDbId) }); + }, + ); private broadcastPlanChange(): void { this.sseBroadcaster?.broadcast({ type: "plan_association_changed" }); diff --git a/console/src/services/worker/http/routes/utils/planFileReader.ts b/console/src/services/worker/http/routes/utils/planFileReader.ts index 29ebcc7c..dd1a8a3c 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: "Feature" | "Bugfix" = + typeMatch?.[1] === "Bugfix" ? "Bugfix" : "Feature"; let phase: "plan" | "implement" | "verify"; if (status === "PENDING" && !approved) { @@ -70,6 +79,7 @@ export function parsePlanContent( iterations, approved, worktree, + specType, filePath, modifiedAt: modifiedAt.toISOString(), }; @@ -95,7 +105,13 @@ export function getWorktreePlansDirs(projectRoot: string): string[] { dirs.push(plansDir); } } - } catch { + } catch (error) { + logger.error( + "HTTP", + "Failed to read worktrees directory", + { worktreesDir }, + error as Error, + ); } return dirs; } @@ -115,13 +131,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 +188,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 +220,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 +234,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 +259,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 +290,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 +302,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..ef64d394 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" : "feature"} +
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" : "Feature"} + + {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, { + year: "numeric", + 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..d3e0a04b 100644 --- a/console/src/ui/viewer/views/Spec/index.tsx +++ b/console/src/ui/viewer/views/Spec/index.tsx @@ -1,19 +1,28 @@ -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, + 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 +34,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 +85,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 +105,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)}${selectedProject ? `&project=${encodeURIComponent(selectedProject)}` : ""}`, + { 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, selectedProject], + ); 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 +193,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 +213,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 +228,10 @@ export function SpecView() { {/* Active plan tabs */} {activeSpecs.length > 0 && ( -
+
{activeSpecs.map((spec) => { const isActive = selectedSpec === spec.filePath; return ( @@ -220,19 +241,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" : "feature"} + {spec.total > 0 && ( - {spec.completed}/{spec.total} + + {spec.completed}/{spec.total} + )} ); @@ -244,7 +274,7 @@ export function SpecView() { {archivedSpecs.length > 0 && (