2026-03-01 21:30:45 -08:00
|
|
|
import fs from 'fs-extra';
|
|
|
|
|
import path from 'path';
|
|
|
|
|
import chalk from 'chalk';
|
|
|
|
|
import ora from 'ora';
|
|
|
|
|
import { generateNewAppIdea, generateEnhancementIdea } from './idea-generator.js';
|
|
|
|
|
import { runSession } from './session-runner.js';
|
|
|
|
|
import { buildStepPrompt } from './prompts/step-instructions.js';
|
|
|
|
|
import { checkAllPhasesComplete } from './step-detector.js';
|
|
|
|
|
import { saveState, loadState, deleteState, findExistingState } from './bot-state.js';
|
|
|
|
|
import type { BotConfig, BotMode, BotState, StepName, StepResult } from './types.js';
|
|
|
|
|
|
|
|
|
|
const BANNER = `
|
|
|
|
|
╔══════════════════════════════════════╗
|
2026-03-01 21:33:57 -08:00
|
|
|
║ plan2code-bot v1.1.0 ║
|
2026-03-01 21:30:45 -08:00
|
|
|
║ Autonomous Workflow Test Runner ║
|
|
|
|
|
╚══════════════════════════════════════╝
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const MAX_IMPLEMENT_PASSES = 10;
|
|
|
|
|
|
|
|
|
|
/** Skill name mapping: bot step name → installed skill directory name */
|
|
|
|
|
const SKILL_MAP: Record<StepName, string> = {
|
|
|
|
|
init: 'plan2code-init',
|
|
|
|
|
plan: 'plan2code-1-plan',
|
|
|
|
|
document: 'plan2code-2-document',
|
|
|
|
|
implement: 'plan2code-3-implement',
|
|
|
|
|
finalize: 'plan2code-4-finalize',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Install bot-friendly copies of plan2code skills into the project's .claude/skills/.
|
|
|
|
|
* The global skills have `disable-model-invocation: true` which prevents the autonomous
|
|
|
|
|
* session from invoking them via the Skill tool. We copy them with that flag removed
|
|
|
|
|
* so the session can invoke the real workflow prompts instead of guessing.
|
|
|
|
|
*/
|
|
|
|
|
export function installSkillsForBot(projectDir: string): void {
|
|
|
|
|
const userSkillsDir = path.join(
|
|
|
|
|
process.env.HOME || process.env.USERPROFILE || '',
|
|
|
|
|
'.claude',
|
|
|
|
|
'skills',
|
|
|
|
|
);
|
|
|
|
|
const projectSkillsDir = path.join(projectDir, '.claude', 'skills');
|
|
|
|
|
|
|
|
|
|
for (const skillName of Object.values(SKILL_MAP)) {
|
|
|
|
|
const srcFile = path.join(userSkillsDir, skillName, 'SKILL.md');
|
|
|
|
|
if (!fs.existsSync(srcFile)) continue;
|
|
|
|
|
|
|
|
|
|
const content = fs.readFileSync(srcFile, 'utf-8');
|
|
|
|
|
// Remove the disable-model-invocation line so the bot session can invoke the skill
|
|
|
|
|
const patched = content.replace(/^disable-model-invocation:\s*true\n?/m, '');
|
|
|
|
|
|
|
|
|
|
const destDir = path.join(projectSkillsDir, skillName);
|
|
|
|
|
fs.ensureDirSync(destDir);
|
|
|
|
|
fs.writeFileSync(path.join(destDir, 'SKILL.md'), patched);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function validateStepArtifacts(step: StepName, projectDir: string): { valid: boolean; missing: string[] } {
|
|
|
|
|
const missing: string[] = [];
|
|
|
|
|
|
|
|
|
|
switch (step) {
|
|
|
|
|
case 'init': {
|
|
|
|
|
const agentsPath = path.join(projectDir, 'AGENTS.md');
|
|
|
|
|
if (!fs.existsSync(agentsPath)) missing.push('AGENTS.md');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'plan': {
|
|
|
|
|
const specsDir = path.join(projectDir, 'specs');
|
|
|
|
|
if (!fs.existsSync(specsDir)) {
|
|
|
|
|
missing.push('specs/ directory');
|
|
|
|
|
} else {
|
|
|
|
|
const entries = fs.readdirSync(specsDir, { withFileTypes: true });
|
|
|
|
|
const specDirs = entries.filter((e) => e.isDirectory());
|
|
|
|
|
const hasPlanDraft = specDirs.some((d) => {
|
|
|
|
|
const files = fs.readdirSync(path.join(specsDir, d.name));
|
|
|
|
|
return files.some((f) => /plan[-_]?draft/i.test(f));
|
|
|
|
|
});
|
|
|
|
|
if (!hasPlanDraft) missing.push('specs/*/PLAN-DRAFT-*.md');
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'document': {
|
|
|
|
|
const specsDir = path.join(projectDir, 'specs');
|
|
|
|
|
if (!fs.existsSync(specsDir)) {
|
|
|
|
|
missing.push('specs/ directory');
|
|
|
|
|
} else {
|
|
|
|
|
const entries = fs.readdirSync(specsDir, { withFileTypes: true });
|
|
|
|
|
const specDirs = entries.filter((e) => e.isDirectory());
|
|
|
|
|
let foundOverview = false;
|
|
|
|
|
let foundPhaseFile = false;
|
|
|
|
|
for (const d of specDirs) {
|
|
|
|
|
const specPath = path.join(specsDir, d.name);
|
|
|
|
|
const files = fs.readdirSync(specPath);
|
|
|
|
|
if (files.some((f) => f === 'overview.md')) foundOverview = true;
|
|
|
|
|
if (files.some((f) => /^phase[-_]?\d+.*\.md$/i.test(f))) foundPhaseFile = true;
|
|
|
|
|
// Also check phases/ subdirectory
|
|
|
|
|
const phasesSubdir = path.join(specPath, 'phases');
|
|
|
|
|
if (fs.existsSync(phasesSubdir)) {
|
|
|
|
|
const subFiles = fs.readdirSync(phasesSubdir);
|
|
|
|
|
if (subFiles.some((f) => /phase/i.test(f))) foundPhaseFile = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!foundOverview) missing.push('specs/*/overview.md');
|
|
|
|
|
if (!foundPhaseFile) missing.push('specs/*/phase-*.md files');
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'finalize': {
|
|
|
|
|
const completedDir = path.join(projectDir, 'specs--completed');
|
|
|
|
|
if (!fs.existsSync(completedDir)) {
|
|
|
|
|
missing.push('specs--completed/ directory');
|
|
|
|
|
} else {
|
|
|
|
|
const entries = fs.readdirSync(completedDir, { withFileTypes: true });
|
|
|
|
|
const specDirs = entries.filter((e) => e.isDirectory());
|
|
|
|
|
if (specDirs.length === 0) missing.push('archived spec in specs--completed/');
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { valid: missing.length === 0, missing };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detectMode(workDir: string): BotMode {
|
|
|
|
|
const agentsPath = path.join(workDir, 'AGENTS.md');
|
|
|
|
|
return fs.existsSync(agentsPath) ? 'enhancement' : 'new-project';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDuration(ms: number): string {
|
|
|
|
|
const seconds = Math.floor(ms / 1000);
|
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
|
const remainingSeconds = seconds % 60;
|
|
|
|
|
if (minutes > 0) {
|
|
|
|
|
return `${minutes}m ${remainingSeconds}s`;
|
|
|
|
|
}
|
|
|
|
|
return `${seconds}s`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runStep(
|
|
|
|
|
step: StepName,
|
|
|
|
|
config: BotConfig,
|
|
|
|
|
state: BotState,
|
|
|
|
|
): Promise<StepResult> {
|
|
|
|
|
const spinner = ora({
|
|
|
|
|
text: chalk.cyan(`Running ${step} step...`),
|
|
|
|
|
spinner: 'dots',
|
|
|
|
|
}).start();
|
|
|
|
|
|
|
|
|
|
const prompt = buildStepPrompt(step, config);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await runSession({
|
|
|
|
|
prompt,
|
|
|
|
|
config,
|
|
|
|
|
step,
|
|
|
|
|
maxTurns: step === 'implement' ? 80 : 50,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const stepResult: StepResult = {
|
|
|
|
|
step,
|
|
|
|
|
success: result.success,
|
|
|
|
|
sessionId: result.sessionId,
|
|
|
|
|
duration: result.duration,
|
|
|
|
|
error: result.success ? null : 'Session failed',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
spinner.succeed(
|
|
|
|
|
chalk.green(`${step} completed in ${formatDuration(result.duration)}`)
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
spinner.fail(chalk.red(`${step} failed after ${formatDuration(result.duration)}`));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return stepResult;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
|
|
|
spinner.fail(chalk.red(`${step} error: ${errorMsg}`));
|
|
|
|
|
return {
|
|
|
|
|
step,
|
|
|
|
|
success: false,
|
|
|
|
|
sessionId: null,
|
|
|
|
|
duration: 0,
|
|
|
|
|
error: errorMsg,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CLIOptions {
|
|
|
|
|
idea?: string;
|
|
|
|
|
resume?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Check if a step completed successfully in a saved state */
|
|
|
|
|
function stepSucceeded(state: BotState, step: StepName): boolean {
|
|
|
|
|
return state.steps.some((s) => s.step === step && s.success);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function runCLI(options: CLIOptions = {}): Promise<void> {
|
|
|
|
|
console.log(chalk.cyan(BANNER));
|
|
|
|
|
|
|
|
|
|
const workDir = process.cwd();
|
|
|
|
|
|
|
|
|
|
// Check for resumable state
|
|
|
|
|
let resuming = false;
|
|
|
|
|
let savedState: BotState | null = null;
|
|
|
|
|
|
|
|
|
|
if (options.resume) {
|
|
|
|
|
savedState = findExistingState(workDir);
|
|
|
|
|
if (savedState) {
|
|
|
|
|
resuming = true;
|
|
|
|
|
console.log(chalk.yellow('Resuming previous incomplete run...'));
|
|
|
|
|
} else {
|
|
|
|
|
console.log(chalk.dim('No previous state found, starting fresh.'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mode = resuming ? savedState!.config.mode : detectMode(workDir);
|
|
|
|
|
|
|
|
|
|
console.log(chalk.dim(`Working directory: ${workDir}`));
|
|
|
|
|
console.log(chalk.dim(`Mode: ${mode === 'new-project' ? 'New Project' : 'Enhancement'}`));
|
|
|
|
|
if (options.idea) {
|
|
|
|
|
console.log(chalk.dim(`Idea seed: ${options.idea}`));
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
let ideaName: string;
|
|
|
|
|
let ideaDescription: string;
|
|
|
|
|
let projectDir: string;
|
|
|
|
|
|
|
|
|
|
if (resuming) {
|
|
|
|
|
// Restore from saved state
|
|
|
|
|
ideaName = savedState!.config.ideaName;
|
|
|
|
|
ideaDescription = savedState!.config.ideaDescription;
|
|
|
|
|
projectDir = savedState!.config.projectDir;
|
|
|
|
|
console.log(chalk.green(`Restored idea: ${ideaName}`));
|
|
|
|
|
console.log(chalk.dim(` ${ideaDescription}`));
|
|
|
|
|
console.log('');
|
|
|
|
|
} else {
|
|
|
|
|
// Step 1: Generate idea
|
|
|
|
|
const ideaSpinner = ora({
|
|
|
|
|
text: chalk.cyan('Generating idea...'),
|
|
|
|
|
spinner: 'dots',
|
|
|
|
|
}).start();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (mode === 'new-project') {
|
|
|
|
|
const idea = await generateNewAppIdea(options.idea);
|
|
|
|
|
ideaName = idea.name;
|
|
|
|
|
ideaDescription = idea.description;
|
|
|
|
|
} else {
|
|
|
|
|
const idea = await generateEnhancementIdea(workDir, options.idea);
|
|
|
|
|
ideaName = idea.name;
|
|
|
|
|
ideaDescription = idea.description;
|
|
|
|
|
}
|
|
|
|
|
ideaSpinner.succeed(chalk.green(`Idea generated: ${ideaName}`));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
ideaSpinner.fail(chalk.red('Failed to generate idea'));
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(chalk.dim(` ${ideaDescription}`));
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
// Determine project directory
|
|
|
|
|
projectDir = mode === 'new-project'
|
|
|
|
|
? path.join(workDir, ideaName)
|
|
|
|
|
: workDir;
|
|
|
|
|
|
|
|
|
|
// Create project directory for new projects
|
|
|
|
|
if (mode === 'new-project') {
|
|
|
|
|
fs.ensureDirSync(projectDir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Install bot-friendly skills (without disable-model-invocation)
|
|
|
|
|
installSkillsForBot(projectDir);
|
|
|
|
|
console.log(chalk.dim('Installed plan2code skills for bot sessions'));
|
|
|
|
|
|
|
|
|
|
// Write IDEA.md (only if not resuming past plan step, since it gets moved)
|
|
|
|
|
if (!resuming || !stepSucceeded(savedState!, 'plan')) {
|
|
|
|
|
const ideaContent = `# ${ideaName}\n\n${ideaDescription}\n`;
|
|
|
|
|
fs.writeFileSync(path.join(projectDir, 'IDEA.md'), ideaContent);
|
|
|
|
|
console.log(chalk.dim(`Wrote IDEA.md to ${projectDir}`));
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
// Build config
|
|
|
|
|
const config: BotConfig = {
|
|
|
|
|
workDir,
|
|
|
|
|
projectDir,
|
|
|
|
|
ideaDescription,
|
|
|
|
|
ideaName,
|
|
|
|
|
mode,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Initialize or restore state
|
|
|
|
|
const state: BotState = resuming
|
|
|
|
|
? { ...savedState!, config }
|
|
|
|
|
: {
|
|
|
|
|
config,
|
|
|
|
|
steps: [],
|
|
|
|
|
currentStep: null,
|
|
|
|
|
implementPasses: 0,
|
|
|
|
|
allPhasesComplete: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Step 2: Run init (new project only)
|
|
|
|
|
if (mode === 'new-project') {
|
|
|
|
|
if (resuming && stepSucceeded(savedState!, 'init')) {
|
|
|
|
|
console.log(chalk.dim('--- Init (skipped — previously succeeded) ---'));
|
|
|
|
|
} else {
|
|
|
|
|
console.log(chalk.bold('--- Init ---'));
|
|
|
|
|
state.currentStep = 'init';
|
|
|
|
|
const initResult = await runStep('init', config, state);
|
|
|
|
|
state.steps.push(initResult);
|
|
|
|
|
saveState(state);
|
|
|
|
|
|
|
|
|
|
if (!initResult.success) {
|
|
|
|
|
console.log(chalk.red('\nInit failed. Aborting.'));
|
|
|
|
|
printSummary(state, workDir, false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const initValidation = validateStepArtifacts('init', projectDir);
|
|
|
|
|
if (!initValidation.valid) {
|
|
|
|
|
console.log(chalk.yellow(` ⚠ Missing artifacts: ${initValidation.missing.join(', ')}`));
|
|
|
|
|
initResult.success = false;
|
|
|
|
|
initResult.error = `Missing artifacts: ${initValidation.missing.join(', ')}`;
|
|
|
|
|
console.log(chalk.red('\nInit artifacts missing. Aborting.'));
|
|
|
|
|
printSummary(state, workDir, false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Step 3: Plan
|
|
|
|
|
if (resuming && stepSucceeded(savedState!, 'plan')) {
|
|
|
|
|
console.log(chalk.dim('--- Plan (skipped — previously succeeded) ---'));
|
|
|
|
|
} else {
|
|
|
|
|
console.log(chalk.bold('--- Plan ---'));
|
|
|
|
|
state.currentStep = 'plan';
|
|
|
|
|
const planResult = await runStep('plan', config, state);
|
|
|
|
|
state.steps.push(planResult);
|
|
|
|
|
saveState(state);
|
|
|
|
|
|
|
|
|
|
if (!planResult.success) {
|
|
|
|
|
console.log(chalk.red('\nPlan step failed. Aborting.'));
|
|
|
|
|
printSummary(state, workDir, false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const planValidation = validateStepArtifacts('plan', projectDir);
|
|
|
|
|
if (!planValidation.valid) {
|
|
|
|
|
console.log(chalk.yellow(` ⚠ Missing artifacts: ${planValidation.missing.join(', ')}`));
|
|
|
|
|
planResult.success = false;
|
|
|
|
|
planResult.error = `Missing artifacts: ${planValidation.missing.join(', ')}`;
|
|
|
|
|
console.log(chalk.red('\nPlan artifacts missing. Aborting.'));
|
|
|
|
|
printSummary(state, workDir, false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Move IDEA.md into the spec directory so it stays with its feature
|
|
|
|
|
const ideaPath = path.join(projectDir, 'IDEA.md');
|
|
|
|
|
if (fs.existsSync(ideaPath)) {
|
|
|
|
|
const specEntries = fs.readdirSync(path.join(projectDir, 'specs'), { withFileTypes: true });
|
|
|
|
|
const firstSpecDir = specEntries.find((e) => e.isDirectory());
|
|
|
|
|
if (firstSpecDir) {
|
|
|
|
|
const dest = path.join(projectDir, 'specs', firstSpecDir.name, 'IDEA.md');
|
|
|
|
|
fs.moveSync(ideaPath, dest, { overwrite: true });
|
|
|
|
|
console.log(chalk.dim(`Moved IDEA.md → specs/${firstSpecDir.name}/IDEA.md`));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
// Step 4: Document
|
|
|
|
|
if (resuming && stepSucceeded(savedState!, 'document')) {
|
|
|
|
|
console.log(chalk.dim('--- Document (skipped — previously succeeded) ---'));
|
|
|
|
|
} else {
|
|
|
|
|
console.log(chalk.bold('--- Document ---'));
|
|
|
|
|
state.currentStep = 'document';
|
|
|
|
|
const docResult = await runStep('document', config, state);
|
|
|
|
|
state.steps.push(docResult);
|
|
|
|
|
saveState(state);
|
|
|
|
|
|
|
|
|
|
if (!docResult.success) {
|
|
|
|
|
console.log(chalk.red('\nDocument step failed. Aborting.'));
|
|
|
|
|
printSummary(state, workDir, false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const docValidation = validateStepArtifacts('document', projectDir);
|
|
|
|
|
if (!docValidation.valid) {
|
|
|
|
|
console.log(chalk.yellow(` ⚠ Missing artifacts: ${docValidation.missing.join(', ')}`));
|
|
|
|
|
docResult.success = false;
|
|
|
|
|
docResult.error = `Missing artifacts: ${docValidation.missing.join(', ')}`;
|
|
|
|
|
console.log(chalk.red('\nDocument artifacts missing. Aborting.'));
|
|
|
|
|
printSummary(state, workDir, false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
// Step 5: Implement (loop until all phases complete)
|
|
|
|
|
if (resuming && savedState!.allPhasesComplete) {
|
|
|
|
|
console.log(chalk.dim('--- Implement (skipped — all phases already complete) ---'));
|
|
|
|
|
} else {
|
|
|
|
|
console.log(chalk.bold('--- Implement ---'));
|
|
|
|
|
while (state.implementPasses < MAX_IMPLEMENT_PASSES) {
|
|
|
|
|
state.implementPasses++;
|
|
|
|
|
state.currentStep = 'implement';
|
|
|
|
|
|
|
|
|
|
console.log(chalk.dim(` Pass ${state.implementPasses}/${MAX_IMPLEMENT_PASSES}`));
|
|
|
|
|
const implResult = await runStep('implement', config, state);
|
|
|
|
|
state.steps.push(implResult);
|
|
|
|
|
saveState(state);
|
|
|
|
|
|
|
|
|
|
if (!implResult.success) {
|
|
|
|
|
console.log(chalk.yellow(`\nImplement pass ${state.implementPasses} failed. Continuing...`));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if all phases are complete
|
|
|
|
|
if (checkAllPhasesComplete(projectDir)) {
|
|
|
|
|
state.allPhasesComplete = true;
|
|
|
|
|
saveState(state);
|
|
|
|
|
console.log(chalk.green(' All phases complete!'));
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!state.allPhasesComplete && state.implementPasses >= MAX_IMPLEMENT_PASSES) {
|
|
|
|
|
console.log(chalk.yellow(`\nMax implement passes (${MAX_IMPLEMENT_PASSES}) reached.`));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
// Step 6: Finalize
|
|
|
|
|
if (resuming && stepSucceeded(savedState!, 'finalize')) {
|
|
|
|
|
console.log(chalk.dim('--- Finalize (skipped — previously succeeded) ---'));
|
|
|
|
|
} else {
|
|
|
|
|
console.log(chalk.bold('--- Finalize ---'));
|
|
|
|
|
state.currentStep = 'finalize';
|
|
|
|
|
const finalizeResult = await runStep('finalize', config, state);
|
|
|
|
|
state.steps.push(finalizeResult);
|
|
|
|
|
saveState(state);
|
|
|
|
|
|
|
|
|
|
if (finalizeResult.success) {
|
|
|
|
|
const finalValidation = validateStepArtifacts('finalize', projectDir);
|
|
|
|
|
if (!finalValidation.valid) {
|
|
|
|
|
console.log(chalk.yellow(` ⚠ Missing artifacts: ${finalValidation.missing.join(', ')}`));
|
|
|
|
|
finalizeResult.success = false;
|
|
|
|
|
finalizeResult.error = `Missing artifacts: ${finalValidation.missing.join(', ')}`;
|
|
|
|
|
saveState(state);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
// Determine overall success
|
|
|
|
|
const allSucceeded = state.steps.length > 0 && state.steps.every((s) => s.success);
|
|
|
|
|
printSummary(state, workDir, allSucceeded);
|
|
|
|
|
|
|
|
|
|
// Clean up state file on full success
|
|
|
|
|
if (allSucceeded) {
|
|
|
|
|
deleteState(projectDir);
|
|
|
|
|
console.log(chalk.dim('Cleaned up state file (run succeeded)'));
|
|
|
|
|
} else {
|
|
|
|
|
console.log(chalk.dim('State file preserved for resume (run incomplete)'));
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function printSummary(state: BotState, workDir: string, allSucceeded: boolean): void {
|
|
|
|
|
const { ideaName, mode, projectDir } = state.config;
|
|
|
|
|
|
|
|
|
|
console.log(chalk.cyan('═══════════════════════════════════════'));
|
|
|
|
|
console.log(chalk.bold(' Bot Run Summary'));
|
|
|
|
|
console.log(chalk.cyan('═══════════════════════════════════════'));
|
|
|
|
|
console.log(chalk.dim(` Project: ${ideaName}`));
|
|
|
|
|
console.log(chalk.dim(` Mode: ${mode}`));
|
|
|
|
|
console.log(chalk.dim(` Directory: ${projectDir}`));
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
const totalDuration = state.steps.reduce((sum, s) => sum + s.duration, 0);
|
|
|
|
|
const successCount = state.steps.filter((s) => s.success).length;
|
|
|
|
|
|
|
|
|
|
for (const step of state.steps) {
|
|
|
|
|
const icon = step.success ? chalk.green('✓') : chalk.red('✗');
|
|
|
|
|
console.log(` ${icon} ${step.step.padEnd(12)} ${formatDuration(step.duration)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('');
|
|
|
|
|
console.log(chalk.dim(` Total: ${successCount}/${state.steps.length} steps succeeded in ${formatDuration(totalDuration)}`));
|
|
|
|
|
console.log(chalk.dim(` Implement passes: ${state.implementPasses}`));
|
|
|
|
|
if (!allSucceeded) {
|
|
|
|
|
console.log(chalk.dim(` State saved to: ${path.relative(workDir, projectDir)}/.plan2code-bot-state.json`));
|
|
|
|
|
}
|
|
|
|
|
console.log('');
|
|
|
|
|
}
|