import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import axios from 'axios'; import inquirer from 'inquirer'; import fs from 'fs/promises'; import path from 'path'; import { simpleGit } from 'simple-git'; import { getApiKey, getApiUrl, getProjectId } from '../utils/config.js'; import { suggestNextMove } from './suggest.js'; import { executePlan } from './plan.js'; export function createWorkCommand(): Command { const work = new Command('work'); work .description('Manage development flow (Start, Finish, List)') .action(() => { // Default action: List tasks if no subcommand // Since commander logic with subcommands is tricky on default, we usually output help // But let's make it interactive list listInteractive(); }); work.command('start') .description('Start a task (Sets status to IN_PROGRESS)') .argument('', 'Task ID (e.g. T-5) or UUID') .action(async (taskId) => { await executePlan(taskId); await setTaskStatus(taskId, 'IN_PROGRESS'); }); work.command('finish') .description('Finish a task (Runs Audit -> Sets COMPLETED -> Suggests Next)') .argument('', 'Task ID (e.g. T-5) or UUID') .action(async (taskId) => { await finishTask(taskId); }); return work; } // === IMPLEMENTATION === async function listInteractive() { const spinner = ora('Fetching roadmap...').start(); try { const { projectId, apiKey, apiUrl } = getContext(); const response = await axios.get( `${apiUrl}/api/v1/roadmap?project_id=${projectId}`, { headers: { 'Authorization': `Bearer ${apiKey}` } } ); if (!response.data.success) throw new Error('Failed to fetch roadmap'); const allTasks: any[] = response.data.data.roadmap || []; // Filter actionable const actionableTasks = allTasks .filter(t => ['ACTIVE', 'LOCKED', 'IN_PROGRESS', 'PENDING'].includes(t.status)) .sort((a, b) => { const statusOrder: Record = { 'IN_PROGRESS': 0, 'ACTIVE': 1, 'LOCKED': 2, 'PENDING': 3 }; const sDiff = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9); if (sDiff !== 0) return sDiff; return a.step_number - b.step_number; }); spinner.stop(); if (actionableTasks.length === 0) { console.log(chalk.yellow('Roadmap clear. No actionable tasks found.')); return; } const choices = actionableTasks.map(t => { const id = `T-${t.step_number}`; let icon = 'šŸ”’'; if (t.status === 'IN_PROGRESS') icon = 'šŸ”„'; if (t.status === 'ACTIVE') icon = 'ā–¶ļø'; return { name: `${icon} ${chalk.bold(id)}: ${t.title} [${t.status}]`, value: t.id }; }); const { taskId } = await inquirer.prompt([{ type: 'list', name: 'taskId', message: 'Select a task to manage:', choices }]); const { action } = await inquirer.prompt([{ type: 'list', name: 'action', message: 'Action:', choices: [ { name: 'Plan (Draft Blueprint - RECOMMENDED)', value: 'plan' }, { name: 'Start (Set IN_PROGRESS)', value: 'start' }, { name: 'Finish (Audit & Complete)', value: 'finish' }, { name: 'Cancel', value: 'cancel' } ] }]); if (action === 'plan') { await executePlan(taskId); // After planning, maybe ask to start? For now, just exit as plan command does sufficient logging. } if (action === 'start') await setTaskStatus(taskId, 'IN_PROGRESS'); if (action === 'finish') await finishTask(taskId); } catch (e: any) { spinner.fail(`Error: ${e.message}`); } } async function setTaskStatus(taskId: string, status: string) { const spinner = ora(`Setting task ${taskId} to ${status}...`).start(); try { const { projectId, apiKey, apiUrl } = getContext(); // Resolve ID if "T-5" format (simple heuristic: if short usage, might need lookup, but specialized endpoint handles UUID usually. // For robustness, let's assume user passes UUID from list OR we need lookup. // Rigstate API usually expects UUID for updates. // Let's do a lookup if it looks like T-X let realId = taskId; if (taskId.startsWith('T-') || taskId.length < 10) { spinner.text = 'Resolving Task ID...'; const lookup = await axios.get(`${apiUrl}/api/v1/roadmap?project_id=${projectId}`, { headers: { Authorization: `Bearer ${apiKey}` } }); const task = lookup.data.data.roadmap.find((t: any) => `T-${t.step_number}` === taskId || t.step_number.toString() === taskId); if (!task) throw new Error(`Task ${taskId} not found.`); realId = task.id; } // Call Update // Note: The API tool `update_roadmap_status` uses 'status' arg. // We probably have an endpoint `/api/v1/roadmap/update-status` or similar from previous code. await axios.post( `${apiUrl}/api/v1/roadmap/update-status`, { step_id: realId, status, project_id: projectId }, { headers: { 'Authorization': `Bearer ${apiKey}` } } ); spinner.succeed(chalk.green(`Task updated to ${status}.`)); if (status === 'IN_PROGRESS') { console.log(chalk.blue(`\nšŸ’” Tip: Provide 'Frank' with context by mentioning @.cursorrules in your chat.`)); } } catch (e: any) { spinner.fail(chalk.red(`Failed: ${e.message}`)); } } async function finishTask(taskId: string) { console.log(''); console.log(chalk.bold.yellow('šŸ›”ļø FRANK\'S QUALITY GATE (Phase 2.2)')); console.log(chalk.dim('────────────────────────────────────────')); try { const { projectId, apiKey, apiUrl } = getContext(); // 1. Checklist Validation (Mandatory) const planPath = path.join(process.cwd(), 'IMPLEMENTATION_PLAN.md'); const planExists = await fs.access(planPath).then(() => true).catch(() => false); if (planExists) { const content = await fs.readFile(planPath, 'utf-8'); const uncompleted = content.match(/- \[ \] .+/g); if (uncompleted && uncompleted.length > 0) { console.log(chalk.red(`\nāŒ Validation Failed: There are ${uncompleted.length} uncompleted items in IMPLEMENTATION_PLAN.md.`)); console.log(chalk.dim('Please complete all items or remove them before finishing.')); const { force } = await inquirer.prompt([{ type: 'confirm', name: 'force', message: 'Do you want to force completion anyway? (Not recommended)', default: false }]); if (!force) return; } } // 2. The Scribe's Analysis (Delta of Reality) const scribeSpinner = ora(' Analyzing "The Delta of Reality" via Scribe...').start(); // Resolve Task ID for Git Trailer search let realId = taskId; let stepNumber = ''; const lookupList = await axios.get(`${apiUrl}/api/v1/roadmap?project_id=${projectId}`, { headers: { Authorization: `Bearer ${apiKey}` } }); const taskObj = lookupList.data.data.roadmap.find((t: any) => t.id === taskId || `T-${t.step_number}` === taskId || t.step_number.toString() === taskId ); if (taskObj) { realId = taskObj.id; stepNumber = `T-${taskObj.step_number}`; } // Capture Git Diff (Search for Task ID in trailers) const git = simpleGit(); let diff = await git.raw(['log', '-p', `--grep=Rigstate-Task: ${stepNumber || taskId}`, '--since="24 hours ago"']); if (!diff) { scribeSpinner.warn(chalk.yellow(' No commits found with high-fidelity Rigstate trailers. Fallback to shallow analysis.')); } else { scribeSpinner.stop(); // āœ‚ļø Truncation logic: If diff is too large (>25k chars), truncate it to avoid LLM context overflow/hangs if (diff.length > 25000) { console.log(chalk.dim(` (Diff is large: ${Math.round(diff.length / 1024)}KB. Truncating for analysis...)`)); diff = diff.substring(0, 25000) + "\n\n[... Diff truncated for brevity ...]"; } } console.log(chalk.cyan(`\n🧐 Frank's Observation:`)); console.log(chalk.dim(' "I\'m comparing your INITIAL INTENT with the ACTUAL CODE changes..."')); const { learning } = await inquirer.prompt([{ type: 'input', name: 'learning', message: 'Any quick notes on why things changed? (Optional)', default: 'Refined during implementation.' }]); // 3. Mark Complete & Save Post-Mortem via API const analyzeSpinner = ora('Requesting Deep Architectural Review (The Scribe)...').start(); try { const response = await axios.post( `${apiUrl}/api/v1/agent/scribe/analyze`, { project_id: projectId, task_id: realId, plan: planExists ? await fs.readFile(planPath, 'utf-8') : '', diff: diff || '', user_notes: learning }, { headers: { 'Authorization': `Bearer ${apiKey}` }, timeout: 120000 // 120s timeout for deep LLM analysis } ); const { postMortem, analysis } = response.data; analyzeSpinner.succeed(`Analysis Complete: ${analysis.intentFulfillment}% Intent Fulfillment.`); // 4. Archive Post-Mortem Locally const pmDir = path.join(process.cwd(), '.rigstate', 'post-mortems'); await fs.mkdir(pmDir, { recursive: true }); const pmPath = path.join(pmDir, `${stepNumber || taskId}.md`.toLowerCase()); await fs.writeFile(pmPath, postMortem); // Success Handshake console.log(''); console.log(chalk.bold.green('šŸŽ‰ TASK COMPLETE! Momentum Preserved.')); if (analysis.proactiveAdvice) { console.log(chalk.dim(` Memory saved to Project Brain: "${analysis.proactiveAdvice.slice(0, 60)}..."`)); } console.log(chalk.dim(` Artifact archived to .rigstate/post-mortems/`)); await suggestNextMove(projectId, apiKey, apiUrl); } catch (e: any) { analyzeSpinner.fail(chalk.red(`Analysis failed: ${e.message}`)); if (e.code === 'ECONNABORTED') { console.log(chalk.dim(' Reason: The Scribe took too long to respond. The diff might still be too complex.')); } } } catch (outerError: any) { console.log(chalk.red(`\nāŒ Quality Gate Error: ${outerError.message}`)); } } function getContext() { const apiKey = getApiKey(); const apiUrl = getApiUrl(); const projectId = getProjectId(); if (!projectId) { throw new Error('Project ID missing. Run rigstate link.'); } // Single source of truth for Auth logging console.log(chalk.dim(` [Auth] Context: Project ID ${projectId.substring(0, 8)}...`)); console.log(chalk.dim(` [Auth] Endpoint: ${apiUrl}`)); return { projectId, apiKey, apiUrl }; }