diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 54a1ef0..7c725bc 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -219,7 +219,7 @@ export type ProgressCallback = (phase: string, detail: string) => void; export interface PlanSubagentEvent { type: 'started' | 'progress' | 'completed' | 'failed'; agentId: string; - agentType: 'research' | 'requirements' | 'architecture' | 'testing' | 'risks' | 'verification' | 'execution' | 'final-review'; + agentType: 'research' | 'requirements' | 'architecture' | 'testing' | 'risks' | 'verification' | 'execution-optimizer' | 'final-review'; model: string; status: string; detail?: string; @@ -276,10 +276,23 @@ function tryParseJSON(jsonString: string): { success: boolean; data?: any; error // 1. Remove trailing commas before ] or } repaired = repaired.replace(/,(\s*[\]}])/g, '$1'); - // 2. Fix unescaped newlines in strings (common LLM issue) - // Match strings and escape newlines within them + // 2. Fix unescaped control characters in strings (common LLM issue) + // JSON requires all control characters (0x00-0x1F) to be escaped + // Match strings and escape all control characters within them repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => { - return match.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'); + // Replace all control characters with their escaped forms + return match.replace(/[\x00-\x1F]/g, (char) => { + switch (char) { + case '\n': return '\\n'; + case '\r': return '\\r'; + case '\t': return '\\t'; + case '\b': return '\\b'; + case '\f': return '\\f'; + default: + // Escape other control characters as \uXXXX + return '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0'); + } + }); }); // 3. Try to close unclosed arrays/objects (truncated output) @@ -351,18 +364,13 @@ export class PlanOrchestrator { this.screenManager = screenManager; this.workingDir = workingDir; this.outputDir = outputDir; + console.log(`[PlanOrchestrator] Constructor called with outputDir: ${outputDir || 'UNDEFINED'}`); } /** - * Save agent prompt and result to the output directory. - * Creates a folder for each agent with prompt.md and result.json files. + * Save agent prompt immediately when agent starts (so UI can show it while running). */ - private saveAgentOutput( - agentType: string, - prompt: string, - result: unknown, - durationMs: number - ): void { + private saveAgentPrompt(agentType: string, prompt: string): void { if (!this.outputDir) return; try { @@ -381,6 +389,43 @@ export class PlanOrchestrator { const promptPath = join(agentDir, 'prompt.md'); const promptContent = `# ${agentType} Agent Prompt +Generated: ${new Date().toISOString()} +Status: Running... + +## Task Description +${this.taskDescription} + +## Prompt +${prompt} +`; + writeFileSync(promptPath, promptContent, 'utf-8'); + console.log(`[PlanOrchestrator] Saved ${agentType} prompt to ${agentDir}`); + } catch (err) { + console.error(`[PlanOrchestrator] Failed to save ${agentType} prompt:`, err); + } + } + + /** + * Save agent result when agent completes. + */ + private saveAgentResult( + agentType: string, + prompt: string, + result: unknown, + durationMs: number + ): void { + if (!this.outputDir) return; + + try { + const agentDir = join(this.outputDir, agentType); + if (!existsSync(agentDir)) { + mkdirSync(agentDir, { recursive: true }); + } + + // Update prompt with duration + const promptPath = join(agentDir, 'prompt.md'); + const promptContent = `# ${agentType} Agent Prompt + Generated: ${new Date().toISOString()} Duration: ${(durationMs / 1000).toFixed(1)}s @@ -396,12 +441,24 @@ ${prompt} const resultPath = join(agentDir, 'result.json'); writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8'); - console.log(`[PlanOrchestrator] Saved ${agentType} output to ${agentDir}`); + console.log(`[PlanOrchestrator] Saved ${agentType} result to ${agentDir}`); } catch (err) { - console.error(`[PlanOrchestrator] Failed to save ${agentType} output:`, err); + console.error(`[PlanOrchestrator] Failed to save ${agentType} result:`, err); } } + /** + * Save agent prompt and result to the output directory (legacy, calls both). + */ + private saveAgentOutput( + agentType: string, + prompt: string, + result: unknown, + durationMs: number + ): void { + this.saveAgentResult(agentType, prompt, result, durationMs); + } + /** * Save the final combined plan result. */ @@ -818,6 +875,9 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: .replace('{TASK}', taskDescription) .replace('{WORKING_DIR}', this.workingDir); + // Save prompt immediately so UI can show it while running + this.saveAgentPrompt('research', prompt); + onProgress?.('research', 'Starting research agent (local project + web search)...'); // Periodic progress updates showing elapsed time with contextual messages @@ -1272,6 +1332,9 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: detail: `Analyzing ${agentType}...`, }); + // Save prompt immediately so UI can show it while running + this.saveAgentPrompt(agentType, prompt); + const session = new Session({ workingDir: this.workingDir, screenManager: this.screenManager, @@ -1635,6 +1698,9 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: .replace('{TASK}', taskDescription) .replace('{PLAN}', planText); + // Save prompt immediately so UI can show it while running + this.saveAgentPrompt('verification', prompt); + onProgress?.('verification', 'Validating plan quality...'); // Periodic progress updates showing elapsed time @@ -1877,7 +1943,7 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: onSubagent?.({ type: 'started', agentId, - agentType: 'execution', + agentType: 'execution-optimizer', model: MODEL_VERIFICATION, status: 'running', detail: 'Optimizing plan for Claude Code execution...', @@ -1901,6 +1967,9 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: .replace('{TASK}', taskDescription) .replace('{PLAN}', planText); + // Save prompt immediately so UI can show it while running + this.saveAgentPrompt('execution-optimizer', prompt); + onProgress?.('execution-optimization', 'Analyzing parallelization opportunities...'); // Periodic progress updates showing elapsed time @@ -1911,7 +1980,7 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: onSubagent?.({ type: 'progress', agentId, - agentType: 'execution', + agentType: 'execution-optimizer', model: MODEL_VERIFICATION, status: 'running', detail: `Optimizing execution... (${elapsedSec}s / ${timeoutSec}s)`, @@ -1941,7 +2010,7 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: onSubagent?.({ type: 'completed', agentId, - agentType: 'execution', + agentType: 'execution-optimizer', model: MODEL_VERIFICATION, status: 'completed', detail: 'Using default execution strategy', @@ -2033,7 +2102,7 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: onSubagent?.({ type: 'completed', agentId, - agentType: 'execution', + agentType: 'execution-optimizer', model: MODEL_VERIFICATION, status: 'completed', itemCount: optimizedPlan.length, @@ -2056,7 +2125,7 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: onSubagent?.({ type: 'failed', agentId, - agentType: 'execution', + agentType: 'execution-optimizer', model: MODEL_VERIFICATION, status: 'failed', error: err instanceof Error ? err.message : String(err), @@ -2129,6 +2198,9 @@ Check \`${caseDir}/ralph-wizard/research/result.json\` for: .replace('{TASK}', taskDescription) .replace('{PLAN}', planText); + // Save prompt immediately so UI can show it while running + this.saveAgentPrompt('final-review', prompt); + onProgress?.('final-review', 'Analyzing overall plan coherence...'); // Periodic progress updates showing elapsed time diff --git a/src/web/public/app.js b/src/web/public/app.js index 9e5190e..a7f3811 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4124,7 +4124,7 @@ class ClaudemanApp { testing: 'TDD Specialist', risks: 'Risk Analyst', verification: 'Verification Expert', - execution: 'Execution Optimizer', + 'execution-optimizer': 'Execution Optimizer', 'final-review': 'Final Review', }; @@ -4135,7 +4135,7 @@ class ClaudemanApp { testing: '🧪', risks: '⚠️', verification: '✓', - execution: '⚡', + 'execution-optimizer': '⚡', 'final-review': '🔍', }; @@ -4580,7 +4580,7 @@ class ClaudemanApp { testing: 'TDD Specialist', risks: 'Risk Analyst', verification: 'Verification Expert', - execution: 'Execution Optimizer', + 'execution-optimizer': 'Execution Optimizer', 'final-review': 'Final Review', }; diff --git a/src/web/server.ts b/src/web/server.ts index c04191d..040ff06 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2531,18 +2531,62 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } // Determine output directory for saving wizard results + // Check linked cases first, then claudeman-cases let outputDir: string | undefined; + console.log(`[API] generate-plan-detailed called with caseName: "${caseName}"`); if (caseName) { - const casesDir = join(homedir(), 'claudeman-cases'); - const casePath = join(casesDir, caseName); - // Security: Path traversal protection - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); - if (resolvedCase.startsWith(resolvedBase) && existsSync(casePath)) { + let casePath: string | undefined; + + // First check linked cases + const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json'); + console.log(`[API] Checking linked cases file: ${linkedCasesFile}`); + try { + if (existsSync(linkedCasesFile)) { + const linkedCases: Record = JSON.parse(readFileSync(linkedCasesFile, 'utf-8')); + console.log(`[API] Linked cases:`, Object.keys(linkedCases)); + console.log(`[API] Looking for caseName "${caseName}" in linked cases`); + if (linkedCases[caseName]) { + console.log(`[API] Found linked case path: ${linkedCases[caseName]}`); + if (existsSync(linkedCases[caseName])) { + casePath = linkedCases[caseName]; + console.log(`[API] Path exists, using: ${casePath}`); + } else { + console.log(`[API] Path does NOT exist!`); + } + } else { + console.log(`[API] caseName "${caseName}" not found in linked cases`); + } + } else { + console.log(`[API] Linked cases file does not exist`); + } + } catch (err) { + console.log(`[API] Error reading linked cases:`, err); + } + + // Fall back to claudeman-cases directory + if (!casePath) { + console.log(`[API] Falling back to claudeman-cases directory`); + const casesDir = join(homedir(), 'claudeman-cases'); + const directPath = join(casesDir, caseName); + // Security: Path traversal protection + const resolvedCase = resolve(directPath); + const resolvedBase = resolve(casesDir); + if (resolvedCase.startsWith(resolvedBase) && existsSync(directPath)) { + casePath = directPath; + } + } + + if (casePath) { outputDir = join(casePath, 'ralph-wizard'); + console.log(`[API] outputDir set to: ${outputDir}`); + } else { + console.log(`[API] WARNING: casePath is undefined, outputDir will be undefined!`); } + } else { + console.log(`[API] WARNING: caseName is falsy, outputDir will be undefined!`); } + console.log(`[API] Final outputDir: ${outputDir || 'UNDEFINED'}`); const orchestrator = new PlanOrchestrator(this.screenManager, process.cwd(), outputDir); // Store orchestrator for potential cancellation via API (not on disconnect) @@ -2643,14 +2687,35 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Get ralph-wizard files for a case (prompts and results) this.app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => { const { caseName } = req.params as { caseName: string }; - const casesDir = join(homedir(), 'claudeman-cases'); - const casePath = join(casesDir, caseName); - // Security: Path traversal protection - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); - if (!resolvedCase.startsWith(resolvedBase)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + // Check linked cases first, then claudeman-cases + let casePath: string | undefined; + + const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json'); + try { + if (existsSync(linkedCasesFile)) { + const linkedCases: Record = JSON.parse(readFileSync(linkedCasesFile, 'utf-8')); + if (linkedCases[caseName] && existsSync(linkedCases[caseName])) { + casePath = linkedCases[caseName]; + } + } + } catch { + // Ignore linked cases errors + } + + if (!casePath) { + const casesDir = join(homedir(), 'claudeman-cases'); + const directPath = join(casesDir, caseName); + // Security: Path traversal protection + const resolvedCase = resolve(directPath); + const resolvedBase = resolve(casesDir); + if (resolvedCase.startsWith(resolvedBase) && existsSync(directPath)) { + casePath = directPath; + } + } + + if (!casePath) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found'); } const wizardDir = join(casePath, 'ralph-wizard'); @@ -2689,14 +2754,35 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Read a specific ralph-wizard file this.app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req) => { const { caseName, filePath } = req.params as { caseName: string; filePath: string }; - const casesDir = join(homedir(), 'claudeman-cases'); - const casePath = join(casesDir, caseName); - // Security: Path traversal protection for case name - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); - if (!resolvedCase.startsWith(resolvedBase)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + // Check linked cases first, then claudeman-cases + let casePath: string | undefined; + + const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json'); + try { + if (existsSync(linkedCasesFile)) { + const linkedCases: Record = JSON.parse(readFileSync(linkedCasesFile, 'utf-8')); + if (linkedCases[caseName] && existsSync(linkedCases[caseName])) { + casePath = linkedCases[caseName]; + } + } + } catch { + // Ignore linked cases errors + } + + if (!casePath) { + const casesDir = join(homedir(), 'claudeman-cases'); + const directPath = join(casesDir, caseName); + // Security: Path traversal protection + const resolvedCase = resolve(directPath); + const resolvedBase = resolve(casesDir); + if (resolvedCase.startsWith(resolvedBase) && existsSync(directPath)) { + casePath = directPath; + } + } + + if (!casePath) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found'); } const wizardDir = join(casePath, 'ralph-wizard');